1use anyhow::{Context, Result};
20use clap::{Parser, Subcommand};
21use std::io::{self, Write};
22use std::path::Path;
23use std::process::Command;
24
25#[derive(Parser, Debug)]
27pub struct DevArgs {
28 #[command(subcommand)]
30 pub command: DevCommands,
31}
32
33#[derive(Subcommand, Debug)]
35pub enum DevCommands {
36 KillPorts(KillPortsArgs),
38 SyncEnv(SyncEnvArgs),
40 Upgrade(UpgradeArgs),
42 InstallHooks,
44}
45
46#[derive(Parser, Debug)]
48pub struct KillPortsArgs {
49 #[arg(required = true)]
51 pub targets: Vec<String>,
52
53 #[arg(short, long)]
55 pub force: bool,
56
57 #[arg(short, long)]
59 pub yes: bool,
60}
61
62#[derive(Parser, Debug)]
64pub struct SyncEnvArgs {
65 #[arg(short, long, default_value = "build,dev,start,test")]
67 pub tasks: String,
68
69 #[arg(short, long)]
71 pub dry_run: bool,
72
73 #[arg(long, default_value_t = 10)]
75 pub max_depth: usize,
76}
77
78#[derive(Parser, Debug)]
80pub struct UpgradeArgs {
81 #[arg(default_value = "all")]
83 pub silo: String,
84}
85
86pub fn run(args: DevArgs) -> Result<()> {
88 match args.command {
89 DevCommands::KillPorts(args) => run_kill_ports(args),
90 DevCommands::SyncEnv(args) => run_sync_env(args),
91 DevCommands::Upgrade(args) => run_upgrade(args),
92 DevCommands::InstallHooks => run_install_hooks(),
93 }
94}
95
96fn run_install_hooks() -> Result<()> {
97 let root = crate::utils::find_project_root();
98 let hooks_dir = root.join(".git-hooks");
99
100 if !hooks_dir.exists() {
101 anyhow::bail!(
102 "Hooks directory '.git-hooks' not found in project root: {}",
103 root.display()
104 );
105 }
106
107 println!("š§ Setting up ResQ git hooks...");
108
109 let status = Command::new("git")
111 .args(["config", "core.hooksPath", ".git-hooks"])
112 .current_dir(&root)
113 .status()
114 .context("Failed to run git config")?;
115
116 if !status.success() {
117 anyhow::bail!("Failed to set git core.hooksPath");
118 }
119
120 let mut count = 0;
122 for entry in std::fs::read_dir(&hooks_dir)? {
123 let entry = entry?;
124 let path = entry.path();
125 if path.is_file() {
126 let name = path.file_name().unwrap().to_string_lossy();
127 if name == "README.md" {
128 continue;
129 }
130
131 #[cfg(unix)]
132 {
133 use std::os::unix::fs::PermissionsExt;
134 let mut perms = std::fs::metadata(&path)?.permissions();
135 perms.set_mode(0o755);
136 std::fs::set_permissions(&path, perms)?;
137 }
138
139 println!(" ⢠{name}");
140 count += 1;
141 }
142 }
143
144 println!("\nā
Successfully installed {count} git hooks!");
145 Ok(())
146}
147
148fn run_upgrade(args: UpgradeArgs) -> Result<()> {
149 let silo = args.silo.to_lowercase();
150 let root = crate::utils::find_project_root();
151
152 println!("š Starting ResQ Polyglot Upgrade (Silo: {silo})...");
153
154 match silo.as_str() {
155 "python" => upgrade_python(&root)?,
156 "rust" => upgrade_rust(&root)?,
157 "js" | "javascript" | "ts" | "typescript" => upgrade_js(&root)?,
158 "cpp" | "c++" => upgrade_cpp(&root)?,
159 "csharp" | "c#" => upgrade_csharp(&root)?,
160 "nix" => upgrade_nix(&root)?,
161 "all" => {
162 let _ = upgrade_nix(&root);
163 let _ = upgrade_python(&root);
164 let _ = upgrade_rust(&root);
165 let _ = upgrade_js(&root);
166 let _ = upgrade_cpp(&root);
167 let _ = upgrade_csharp(&root);
168 }
169 _ => anyhow::bail!("Unknown silo: {silo}. Valid: python, rust, js, cpp, csharp, nix, all"),
170 }
171
172 println!("\nā
Upgrade complete!");
173 Ok(())
174}
175
176fn upgrade_python(root: &Path) -> Result<()> {
177 println!("\n[Python/uv] Upgrading dependencies...");
178 let _ = Command::new("uv")
179 .args(["lock", "--upgrade"])
180 .current_dir(root)
181 .status();
182 let _ = Command::new("uv").args(["sync"]).current_dir(root).status();
183 Ok(())
184}
185
186fn upgrade_rust(root: &Path) -> Result<()> {
187 println!("\n[Rust/cargo] Upgrading dependencies...");
188 let has_upgrade = Command::new("cargo")
189 .arg("upgrade")
190 .arg("--version")
191 .output()
192 .is_ok();
193 if has_upgrade {
194 let _ = Command::new("cargo")
195 .args(["upgrade", "--workspace"])
196 .current_dir(root)
197 .status();
198 }
199 let _ = Command::new("cargo")
200 .arg("update")
201 .current_dir(root)
202 .status();
203 Ok(())
204}
205
206fn upgrade_js(root: &Path) -> Result<()> {
207 println!("\n[JS/TS/bun] Upgrading dependencies...");
208 let _ = Command::new("bun")
209 .args([
210 "x",
211 "npm-check-updates",
212 "-u",
213 "--packageManager",
214 "bun",
215 "--workspaces",
216 "--root",
217 ])
218 .current_dir(root)
219 .status();
220 let _ = Command::new("bun")
221 .arg("install")
222 .current_dir(root)
223 .status();
224 Ok(())
225}
226
227fn upgrade_cpp(root: &Path) -> Result<()> {
228 println!("\n[C++] Upgrading dependencies...");
229 for entry in walkdir::WalkDir::new(root)
230 .max_depth(4)
231 .into_iter()
232 .flatten()
233 {
234 let name = entry.file_name().to_string_lossy();
235 if name == "conanfile.txt" || name == "conanfile.py" {
236 let dir = entry
237 .path()
238 .parent()
239 .expect("Conan file should have a parent directory");
240 println!(" Found Conan config in {}. Upgrading...", dir.display());
241 let _ = Command::new("conan")
242 .args(["install", ".", "--update", "--build=missing"])
243 .current_dir(dir)
244 .status();
245 }
246 }
247 Ok(())
248}
249
250fn upgrade_csharp(root: &Path) -> Result<()> {
251 println!("\n[C#] Upgrading dependencies...");
252 let _ = Command::new("dotnet")
253 .args(["outdated", "--upgrade"])
254 .current_dir(root)
255 .status();
256 let _ = Command::new("dotnet")
257 .arg("restore")
258 .current_dir(root)
259 .status();
260 Ok(())
261}
262
263fn upgrade_nix(root: &Path) -> Result<()> {
264 if root.join("flake.nix").exists() {
265 println!("\n[Nix] Updating flake lockfile...");
266 let _ = Command::new("nix")
267 .args(["flake", "update"])
268 .current_dir(root)
269 .status();
270 }
271 Ok(())
272}
273
274fn run_sync_env(args: SyncEnvArgs) -> Result<()> {
275 let root = crate::utils::find_project_root();
276 let turbo_path = root.join("turbo.json");
277
278 if !turbo_path.exists() {
279 anyhow::bail!(
280 "turbo.json not found in project root: {}",
281 turbo_path.display()
282 );
283 }
284
285 println!("š Scanning for environment files in {}...", root.display());
286
287 let tasks: Vec<String> = args
288 .tasks
289 .split(',')
290 .map(|s| s.trim().to_string())
291 .collect();
292 let mut env_vars = std::collections::HashSet::new();
293
294 let mut stack = vec![(root.clone(), 0)];
295 while let Some((dir, depth)) = stack.pop() {
296 if depth > args.max_depth {
297 continue;
298 }
299
300 let entries = match std::fs::read_dir(&dir) {
301 Ok(e) => e,
302 Err(_) => continue,
303 };
304
305 for entry in entries.flatten() {
306 let path = entry.path();
307 let name = entry.file_name();
308 let name_str = name.to_string_lossy();
309
310 if path.is_dir() {
311 if name_str == "node_modules" || name_str == ".git" || name_str == "target" {
312 continue;
313 }
314 stack.push((path, depth + 1));
315 } else if path.is_file()
316 && (name_str == ".env.example" || name_str.ends_with(".env.example"))
317 {
318 println!(
319 " š Reading {}",
320 path.strip_prefix(&root).unwrap_or(&path).display()
321 );
322 if let Ok(content) = std::fs::read_to_string(&path) {
323 for line in content.lines() {
324 let trimmed = line.trim();
325 if trimmed.is_empty() || trimmed.starts_with('#') {
326 continue;
327 }
328 let Some(equal_idx) = trimmed.find('=') else {
329 continue;
330 };
331 let var_name = trimmed[..equal_idx].trim();
332 if !var_name.is_empty() {
333 env_vars.insert(var_name.to_string());
334 }
335 }
336 }
337 }
338 }
339 }
340
341 if env_vars.is_empty() {
342 println!("ā ļø No environment variables found in .env.example files.");
343 return Ok(());
344 }
345
346 let mut sorted_vars: Vec<_> = env_vars.into_iter().collect();
347 sorted_vars.sort();
348
349 println!(
350 "š§ Found {} unique environment variables.",
351 sorted_vars.len()
352 );
353
354 let turbo_content = std::fs::read_to_string(&turbo_path)?;
355 let mut turbo_json: serde_json::Value = serde_json::from_str(&turbo_content)?;
356
357 if let Some(tasks_obj) = turbo_json.get_mut("tasks").and_then(|t| t.as_object_mut()) {
358 for task in tasks {
359 if let Some(task_config) = tasks_obj.get_mut(&task).and_then(|t| t.as_object_mut()) {
360 println!(" ā
Updating task: {task}");
361 task_config.insert("env".to_string(), serde_json::to_value(&sorted_vars)?);
362 }
363 }
364 }
365
366 if args.dry_run {
367 println!("\nš DRY RUN - Preview of updated turbo.json tasks:");
368 if let Some(tasks_obj) = turbo_json.get_mut("tasks") {
369 println!("{}", serde_json::to_string_pretty(tasks_obj)?);
370 }
371 } else {
372 let updated_content = serde_json::to_string_pretty(&turbo_json)? + "\n";
373 std::fs::write(&turbo_path, updated_content)?;
374 println!("\nā
Successfully updated turbo.json!");
375 }
376
377 Ok(())
378}
379
380fn run_kill_ports(args: KillPortsArgs) -> Result<()> {
381 let mut ports = Vec::new();
382 for target in args.targets {
383 let target_str: &str = ⌖
384 if target_str.contains("..") {
385 let parts: Vec<&str> = target_str.split("..").collect();
386 if parts.len() == 2 {
387 let start: u16 = parts[0].parse().context("Invalid start port")?;
388 let end: u16 = parts[1].parse().context("Invalid end port")?;
389 for p in start..=end {
390 ports.push(p);
391 }
392 }
393 } else {
394 let p: u16 = target_str.parse().context("Invalid port")?;
395 ports.push(p);
396 }
397 }
398
399 if ports.is_empty() {
400 println!("No ports specified.");
401 return Ok(());
402 }
403
404 println!("š Searching for processes on ports: {ports:?}...");
405
406 let ports_str = ports
407 .iter()
408 .map(|p: &u16| p.to_string())
409 .collect::<Vec<_>>()
410 .join(",");
411 let output = Command::new("lsof")
412 .args([
413 "-i",
414 &format!("TCP:{ports_str}"),
415 "-sTCP:LISTEN",
416 "-P",
417 "-n",
418 "-t",
419 ])
420 .output()
421 .context("Failed to run lsof. Is it installed?")?;
422
423 let pids_raw = String::from_utf8_lossy(&output.stdout);
424 let pids: Vec<&str> = pids_raw.lines().filter(|l| !l.trim().is_empty()).collect();
425
426 if pids.is_empty() {
427 println!("ā
No processes found listening on these ports.");
428 return Ok(());
429 }
430
431 println!("ā ļø Found {} process(es):", pids.len());
432 for pid in &pids {
433 let info = Command::new("ps")
434 .args(["-p", pid, "-o", "comm="])
435 .output()
436 .ok();
437 let comm = info.map_or_else(
438 || "unknown".into(),
439 |o| String::from_utf8_lossy(&o.stdout).trim().to_string(),
440 );
441 println!(" - PID {pid} ({comm})");
442 }
443
444 if !args.yes && !args.force {
445 print!("\nTerminate these processes? [y/N]: ");
446 io::stdout().flush()?;
447 let mut input = String::new();
448 io::stdin().read_line(&mut input)?;
449 if !input.trim().eq_ignore_ascii_case("y") {
450 println!("Aborted.");
451 return Ok(());
452 }
453 }
454
455 let signal = if args.force { "-9" } else { "-15" };
456 let mut success = 0;
457 let mut failed = 0;
458
459 for pid in pids {
460 let status = Command::new("kill").args([signal, pid]).status();
461
462 if status.is_ok_and(|s| s.success()) {
463 success += 1;
464 } else {
465 failed += 1;
466 }
467 }
468
469 println!("\nSummary:");
470 println!(" ā
Successfully signaled {success} process(es).");
471 if failed > 0 {
472 println!(" ā Failed to signal {failed} process(es). (Try with sudo?)");
473 }
474
475 Ok(())
476}