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š Setup Complete!");
149 println!("āāāāāāāāāāāāāāāāā");
150 println!("Cascade CLI is now configured for your repository.");
151 println!();
152 println!("š” Next steps:");
153 println!(" 1. Create your first stack: ca stack create \"My Feature\"");
154 println!(" 2. Push commits to the stack: ca push");
155 println!(" 3. Submit for review: ca submit");
156 println!(" 4. Check status: ca status");
157 println!();
158 println!("š Learn more:");
159 println!(" ⢠Run 'ca --help' for all commands");
160 println!(" ⢠Run 'ca doctor' to verify your setup");
161 println!(" ⢠Run 'ca hooks status' to check hook installation");
162 println!(" ⢠Visit docs/HOOKS.md for hook details");
163 println!(" ⢠Visit the documentation for advanced usage");
164
165 Ok(())
166}
167
168#[derive(Debug)]
169struct BitbucketConfig {
170 url: String,
171 project: String,
172 repo: String,
173 token: Option<String>,
174}
175
176fn detect_bitbucket_config(git_repo: &GitRepository) -> Result<Option<(String, String, String)>> {
178 let remote_url = match git_repo.get_remote_url("origin") {
180 Ok(url) => url,
181 Err(_) => return Ok(None),
182 };
183
184 if let Some(config) = parse_bitbucket_url(&remote_url) {
186 Ok(Some(config))
187 } else {
188 Ok(None)
189 }
190}
191
192fn parse_bitbucket_url(url: &str) -> Option<(String, String, String)> {
194 if url.starts_with("git@") {
196 if let Some(parts) = url.split('@').nth(1) {
197 if let Some((host, path)) = parts.split_once(':') {
198 let base_url = format!("https://{host}");
199 if let Some((project, repo)) = path.split_once('/') {
200 let repo_name = repo.strip_suffix(".git").unwrap_or(repo);
201 return Some((base_url, project.to_string(), repo_name.to_string()));
202 }
203 }
204 }
205 }
206
207 if url.starts_with("https://") {
209 if let Ok(parsed_url) = url::Url::parse(url) {
210 if let Some(host) = parsed_url.host_str() {
211 let base_url = format!("{}://{}", parsed_url.scheme(), host);
212 let path = parsed_url.path();
213
214 if path.starts_with("/scm/") {
216 let path_parts: Vec<&str> =
217 path.trim_start_matches("/scm/").split('/').collect();
218 if path_parts.len() >= 2 {
219 let project = path_parts[0];
220 let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
221 return Some((base_url, project.to_string(), repo.to_string()));
222 }
223 }
224
225 let path_parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
227 if path_parts.len() >= 2 {
228 let project = path_parts[0];
229 let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
230 return Some((base_url, project.to_string(), repo.to_string()));
231 }
232 }
233 }
234 }
235
236 None
237}
238
239async fn configure_bitbucket_interactive(
241 auto_config: Option<(String, String, String)>,
242) -> Result<BitbucketConfig> {
243 let theme = ColorfulTheme::default();
244
245 let default_url = auto_config
247 .as_ref()
248 .map(|(url, _, _)| url.as_str())
249 .unwrap_or("");
250 let url: String = Input::with_theme(&theme)
251 .with_prompt("Bitbucket Server URL")
252 .with_initial_text(default_url)
253 .validate_with(|input: &String| -> std::result::Result<(), &str> {
254 if input.starts_with("http://") || input.starts_with("https://") {
255 Ok(())
256 } else {
257 Err("URL must start with http:// or https://")
258 }
259 })
260 .interact_text()
261 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
262
263 let default_project = auto_config
265 .as_ref()
266 .map(|(_, project, _)| project.as_str())
267 .unwrap_or("");
268 let project: String = Input::with_theme(&theme)
269 .with_prompt("Project key (usually uppercase)")
270 .with_initial_text(default_project)
271 .validate_with(|input: &String| -> std::result::Result<(), &str> {
272 if input.trim().is_empty() {
273 Err("Project key cannot be empty")
274 } else {
275 Ok(())
276 }
277 })
278 .interact_text()
279 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
280
281 let default_repo = auto_config
283 .as_ref()
284 .map(|(_, _, repo)| repo.as_str())
285 .unwrap_or("");
286 let repo: String = Input::with_theme(&theme)
287 .with_prompt("Repository slug")
288 .with_initial_text(default_repo)
289 .validate_with(|input: &String| -> std::result::Result<(), &str> {
290 if input.trim().is_empty() {
291 Err("Repository slug cannot be empty")
292 } else {
293 Ok(())
294 }
295 })
296 .interact_text()
297 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
298
299 println!("\nš Authentication Setup");
301 println!(" Cascade needs a Personal Access Token to interact with Bitbucket.");
302 println!(" You can create one at: {url}/plugins/servlet/access-tokens/manage");
303 println!(" Required permissions: Repository Read, Repository Write");
304
305 let configure_token = Confirm::with_theme(&theme)
306 .with_prompt("Configure authentication token now?")
307 .default(true)
308 .interact()
309 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
310
311 let token = if configure_token {
312 let token: String = Input::with_theme(&theme)
313 .with_prompt("Personal Access Token")
314 .interact_text()
315 .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
316
317 if token.trim().is_empty() {
318 None
319 } else {
320 Some(token.trim().to_string())
321 }
322 } else {
323 println!(" š” You can configure the token later with:");
324 println!(" ca config set bitbucket.token YOUR_TOKEN");
325 None
326 };
327
328 Ok(BitbucketConfig {
329 url,
330 project,
331 repo,
332 token,
333 })
334}
335
336async fn test_bitbucket_connection(settings: &Settings) -> Result<()> {
338 use crate::bitbucket::BitbucketClient;
339
340 let client = BitbucketClient::new(&settings.bitbucket)?;
341
342 match client.get_repository_info().await {
344 Ok(_) => {
345 info!("Successfully connected to Bitbucket");
346 Ok(())
347 }
348 Err(e) => Err(CascadeError::config(format!(
349 "Failed to connect to Bitbucket: {e}"
350 ))),
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn test_parse_bitbucket_ssh_url() {
360 let url = "git@bitbucket.example.com:MYPROJECT/my-repo.git";
361 let result = parse_bitbucket_url(url);
362 assert_eq!(
363 result,
364 Some((
365 "https://bitbucket.example.com".to_string(),
366 "MYPROJECT".to_string(),
367 "my-repo".to_string()
368 ))
369 );
370 }
371
372 #[test]
373 fn test_parse_bitbucket_https_url() {
374 let url = "https://bitbucket.example.com/scm/MYPROJECT/my-repo.git";
375 let result = parse_bitbucket_url(url);
376 assert_eq!(
377 result,
378 Some((
379 "https://bitbucket.example.com".to_string(),
380 "MYPROJECT".to_string(),
381 "my-repo".to_string()
382 ))
383 );
384 }
385
386 #[test]
387 fn test_parse_generic_https_url() {
388 let url = "https://git.example.com/MYPROJECT/my-repo.git";
389 let result = parse_bitbucket_url(url);
390 assert_eq!(
391 result,
392 Some((
393 "https://git.example.com".to_string(),
394 "MYPROJECT".to_string(),
395 "my-repo".to_string()
396 ))
397 );
398 }
399}