1use anyhow::{Context, Result};
20use clap::{Parser, Subcommand};
21use std::io::{self, Write};
22use std::path::Path;
23use std::process::Command;
24
25use crate::commands::hook_templates::HOOK_TEMPLATES;
26
27#[derive(Parser, Debug)]
29pub struct DevArgs {
30 #[command(subcommand)]
32 pub command: DevCommands,
33}
34
35#[derive(Subcommand, Debug)]
37pub enum DevCommands {
38 KillPorts(KillPortsArgs),
40 SyncEnv(SyncEnvArgs),
42 Upgrade(UpgradeArgs),
44 InstallHooks,
46 ScaffoldLocalHook(ScaffoldLocalHookArgs),
48}
49
50#[derive(Parser, Debug)]
52pub struct ScaffoldLocalHookArgs {
53 #[arg(long, default_value = "auto")]
56 pub kind: String,
57
58 #[arg(long, default_value = "pre-push")]
60 pub hook: String,
61
62 #[arg(long)]
64 pub force: bool,
65}
66
67#[derive(Parser, Debug)]
69pub struct KillPortsArgs {
70 #[arg(required = true)]
72 pub targets: Vec<String>,
73
74 #[arg(short, long)]
76 pub force: bool,
77
78 #[arg(short, long)]
80 pub yes: bool,
81}
82
83#[derive(Parser, Debug)]
85pub struct SyncEnvArgs {
86 #[arg(short, long, default_value = "build,dev,start,test")]
88 pub tasks: String,
89
90 #[arg(short, long)]
92 pub dry_run: bool,
93
94 #[arg(long, default_value_t = 10)]
96 pub max_depth: usize,
97}
98
99#[derive(Parser, Debug)]
101pub struct UpgradeArgs {
102 #[arg(default_value = "all")]
104 pub silo: String,
105}
106
107pub fn run(args: DevArgs) -> Result<()> {
109 match args.command {
110 DevCommands::KillPorts(args) => run_kill_ports(args),
111 DevCommands::SyncEnv(args) => run_sync_env(args),
112 DevCommands::Upgrade(args) => run_upgrade(args),
113 DevCommands::InstallHooks => run_install_hooks(),
114 DevCommands::ScaffoldLocalHook(args) => run_scaffold_local_hook(args),
115 }
116}
117
118fn run_install_hooks() -> Result<()> {
119 let root = crate::utils::find_project_root();
120 let hooks_dir = root.join(".git-hooks");
121
122 std::fs::create_dir_all(&hooks_dir)
127 .with_context(|| format!("Failed to create {}", hooks_dir.display()))?;
128 let mut scaffolded = 0u32;
129 for (name, body) in HOOK_TEMPLATES {
130 let dest = hooks_dir.join(name);
131 if dest.exists() {
132 continue;
133 }
134 std::fs::write(&dest, body)
135 .with_context(|| format!("Failed to write {}", dest.display()))?;
136 scaffolded += 1;
137 }
140 if scaffolded > 0 {
141 println!("š Scaffolded {scaffolded} canonical hook(s) from embedded templates.");
142 }
143
144 println!("š§ Setting up ResQ git hooks...");
145
146 let status = Command::new("git")
148 .args(["config", "core.hooksPath", ".git-hooks"])
149 .current_dir(&root)
150 .status()
151 .context("Failed to run git config")?;
152
153 if !status.success() {
154 anyhow::bail!("Failed to set git core.hooksPath");
155 }
156
157 let mut count = 0;
159 for entry in std::fs::read_dir(&hooks_dir)? {
160 let entry = entry?;
161 let path = entry.path();
162 if path.is_file() {
163 let name = path.file_name().unwrap().to_string_lossy();
164 if name == "README.md" {
165 continue;
166 }
167
168 #[cfg(unix)]
169 {
170 use std::os::unix::fs::PermissionsExt;
171 let mut perms = std::fs::metadata(&path)?.permissions();
172 perms.set_mode(0o755);
173 std::fs::set_permissions(&path, perms)?;
174 }
175
176 println!(" ⢠{name}");
177 count += 1;
178 }
179 }
180
181 println!("\nā
Successfully installed {count} git hooks!");
182 Ok(())
183}
184
185const LOCAL_HOOK_TEMPLATES: &[(&str, &str, &str)] = &[
188 (
189 "rust",
190 "pre-push",
191 include_str!("../../templates/local-hooks/rust/pre-push"),
192 ),
193 (
194 "python",
195 "pre-push",
196 include_str!("../../templates/local-hooks/python/pre-push"),
197 ),
198 (
199 "node",
200 "pre-push",
201 include_str!("../../templates/local-hooks/node/pre-push"),
202 ),
203 (
204 "dotnet",
205 "pre-push",
206 include_str!("../../templates/local-hooks/dotnet/pre-push"),
207 ),
208 (
209 "cpp",
210 "pre-push",
211 include_str!("../../templates/local-hooks/cpp/pre-push"),
212 ),
213 (
214 "nix",
215 "pre-push",
216 include_str!("../../templates/local-hooks/nix/pre-push"),
217 ),
218];
219
220fn detect_kind(root: &Path) -> Option<&'static str> {
221 if root.join("Cargo.toml").exists() {
222 return Some("rust");
223 }
224 if root.join("pyproject.toml").exists()
225 || root.join("uv.lock").exists()
226 || root.join("requirements.txt").exists()
227 || root.join("Pipfile").exists()
228 || root.join("setup.py").exists()
229 {
230 return Some("python");
231 }
232 if root.join("package.json").exists()
233 || root.join("bun.lockb").exists()
234 || root.join("bun.lock").exists()
235 || root.join("package-lock.json").exists()
236 || root.join("yarn.lock").exists()
237 || root.join("pnpm-lock.yaml").exists()
238 {
239 return Some("node");
240 }
241 if let Ok(rd) = std::fs::read_dir(root) {
243 for entry in rd.flatten() {
244 if let Some(ext) = entry.path().extension().and_then(|e| e.to_str()) {
245 let lower = ext.to_ascii_lowercase();
246 if matches!(lower.as_str(), "sln" | "csproj" | "fsproj" | "vbproj") {
247 return Some("dotnet");
248 }
249 }
250 }
251 }
252 if root.join("CMakeLists.txt").exists()
253 || root.join("conanfile.txt").exists()
254 || root.join("conanfile.py").exists()
255 {
256 return Some("cpp");
257 }
258 if root.join("flake.nix").exists() {
259 return Some("nix");
260 }
261 None
262}
263
264fn run_scaffold_local_hook(args: ScaffoldLocalHookArgs) -> Result<()> {
265 let root = crate::utils::find_project_root();
266
267 const KNOWN_KINDS: &[&str] = &["rust", "python", "node", "dotnet", "cpp", "nix"];
268 let kind: &str = if args.kind == "auto" {
269 detect_kind(&root).context(
270 "Could not auto-detect repo kind. Pass --kind <rust|python|node|dotnet|cpp|nix>.",
271 )?
272 } else if KNOWN_KINDS.contains(&args.kind.as_str()) {
273 args.kind.as_str()
274 } else {
275 anyhow::bail!(
276 "Unknown --kind '{}'. Valid: {}.",
277 args.kind,
278 KNOWN_KINDS.join(", ")
279 );
280 };
281
282 let body = LOCAL_HOOK_TEMPLATES
283 .iter()
284 .find(|(k, h, _)| *k == kind && *h == args.hook)
285 .map(|(_, _, c)| *c)
286 .with_context(|| {
287 format!(
288 "No local-hook template for kind={kind} hook={}. \
289 Currently supported: pre-push.",
290 args.hook
291 )
292 })?;
293
294 let hooks_dir = root.join(".git-hooks");
295 std::fs::create_dir_all(&hooks_dir)
296 .with_context(|| format!("Failed to create {}", hooks_dir.display()))?;
297
298 let dest = hooks_dir.join(format!("local-{}", args.hook));
299 if dest.exists() && !args.force {
300 anyhow::bail!(
301 "{} already exists. Pass --force to overwrite.",
302 dest.display()
303 );
304 }
305
306 std::fs::write(&dest, body).with_context(|| format!("Failed to write {}", dest.display()))?;
307 #[cfg(unix)]
308 {
309 use std::os::unix::fs::PermissionsExt;
310 let mut perms = std::fs::metadata(&dest)?.permissions();
311 perms.set_mode(0o755);
312 std::fs::set_permissions(&dest, perms)?;
313 }
314
315 println!("ā
Wrote {} ({} template).", dest.display(), kind);
316 Ok(())
317}
318
319fn run_upgrade(args: UpgradeArgs) -> Result<()> {
320 let silo = args.silo.to_lowercase();
321 let root = crate::utils::find_project_root();
322
323 println!("š Starting ResQ Polyglot Upgrade (Silo: {silo})...");
324
325 match silo.as_str() {
326 "python" => upgrade_python(&root)?,
327 "rust" => upgrade_rust(&root)?,
328 "js" | "javascript" | "ts" | "typescript" => upgrade_js(&root)?,
329 "cpp" | "c++" => upgrade_cpp(&root)?,
330 "csharp" | "c#" => upgrade_csharp(&root)?,
331 "nix" => upgrade_nix(&root)?,
332 "all" => {
333 let _ = upgrade_nix(&root);
334 let _ = upgrade_python(&root);
335 let _ = upgrade_rust(&root);
336 let _ = upgrade_js(&root);
337 let _ = upgrade_cpp(&root);
338 let _ = upgrade_csharp(&root);
339 }
340 _ => anyhow::bail!("Unknown silo: {silo}. Valid: python, rust, js, cpp, csharp, nix, all"),
341 }
342
343 println!("\nā
Upgrade complete!");
344 Ok(())
345}
346
347fn upgrade_python(root: &Path) -> Result<()> {
348 println!("\n[Python/uv] Upgrading dependencies...");
349 let _ = Command::new("uv")
350 .args(["lock", "--upgrade"])
351 .current_dir(root)
352 .status();
353 let _ = Command::new("uv").args(["sync"]).current_dir(root).status();
354 Ok(())
355}
356
357fn upgrade_rust(root: &Path) -> Result<()> {
358 println!("\n[Rust/cargo] Upgrading dependencies...");
359 let has_upgrade = Command::new("cargo")
360 .arg("upgrade")
361 .arg("--version")
362 .output()
363 .is_ok();
364 if has_upgrade {
365 let _ = Command::new("cargo")
366 .args(["upgrade", "--workspace"])
367 .current_dir(root)
368 .status();
369 }
370 let _ = Command::new("cargo")
371 .arg("update")
372 .current_dir(root)
373 .status();
374 Ok(())
375}
376
377fn upgrade_js(root: &Path) -> Result<()> {
378 println!("\n[JS/TS/bun] Upgrading dependencies...");
379 let _ = Command::new("bun")
380 .args([
381 "x",
382 "npm-check-updates",
383 "-u",
384 "--packageManager",
385 "bun",
386 "--workspaces",
387 "--root",
388 ])
389 .current_dir(root)
390 .status();
391 let _ = Command::new("bun")
392 .arg("install")
393 .current_dir(root)
394 .status();
395 Ok(())
396}
397
398fn upgrade_cpp(root: &Path) -> Result<()> {
399 println!("\n[C++] Upgrading dependencies...");
400 for entry in walkdir::WalkDir::new(root)
401 .max_depth(4)
402 .into_iter()
403 .flatten()
404 {
405 let name = entry.file_name().to_string_lossy();
406 if name == "conanfile.txt" || name == "conanfile.py" {
407 let dir = entry
408 .path()
409 .parent()
410 .expect("Conan file should have a parent directory");
411 println!(" Found Conan config in {}. Upgrading...", dir.display());
412 let _ = Command::new("conan")
413 .args(["install", ".", "--update", "--build=missing"])
414 .current_dir(dir)
415 .status();
416 }
417 }
418 Ok(())
419}
420
421fn upgrade_csharp(root: &Path) -> Result<()> {
422 println!("\n[C#] Upgrading dependencies...");
423 let _ = Command::new("dotnet")
424 .args(["outdated", "--upgrade"])
425 .current_dir(root)
426 .status();
427 let _ = Command::new("dotnet")
428 .arg("restore")
429 .current_dir(root)
430 .status();
431 Ok(())
432}
433
434fn upgrade_nix(root: &Path) -> Result<()> {
435 if root.join("flake.nix").exists() {
436 println!("\n[Nix] Updating flake lockfile...");
437 let _ = Command::new("nix")
438 .args(["flake", "update"])
439 .current_dir(root)
440 .status();
441 }
442 Ok(())
443}
444
445fn run_sync_env(args: SyncEnvArgs) -> Result<()> {
446 let root = crate::utils::find_project_root();
447 let turbo_path = root.join("turbo.json");
448
449 if !turbo_path.exists() {
450 anyhow::bail!(
451 "turbo.json not found in project root: {}",
452 turbo_path.display()
453 );
454 }
455
456 println!("š Scanning for environment files in {}...", root.display());
457
458 let tasks: Vec<String> = args
459 .tasks
460 .split(',')
461 .map(|s| s.trim().to_string())
462 .collect();
463 let mut env_vars = std::collections::HashSet::new();
464
465 let mut stack = vec![(root.clone(), 0)];
466 while let Some((dir, depth)) = stack.pop() {
467 if depth > args.max_depth {
468 continue;
469 }
470
471 let entries = match std::fs::read_dir(&dir) {
472 Ok(e) => e,
473 Err(_) => continue,
474 };
475
476 for entry in entries.flatten() {
477 let path = entry.path();
478 let name = entry.file_name();
479 let name_str = name.to_string_lossy();
480
481 if path.is_dir() {
482 if name_str == "node_modules" || name_str == ".git" || name_str == "target" {
483 continue;
484 }
485 stack.push((path, depth + 1));
486 } else if path.is_file()
487 && (name_str == ".env.example" || name_str.ends_with(".env.example"))
488 {
489 println!(
490 " š Reading {}",
491 path.strip_prefix(&root).unwrap_or(&path).display()
492 );
493 if let Ok(content) = std::fs::read_to_string(&path) {
494 for line in content.lines() {
495 let trimmed = line.trim();
496 if trimmed.is_empty() || trimmed.starts_with('#') {
497 continue;
498 }
499 let Some(equal_idx) = trimmed.find('=') else {
500 continue;
501 };
502 let var_name = trimmed[..equal_idx].trim();
503 if !var_name.is_empty() {
504 env_vars.insert(var_name.to_string());
505 }
506 }
507 }
508 }
509 }
510 }
511
512 if env_vars.is_empty() {
513 println!("ā ļø No environment variables found in .env.example files.");
514 return Ok(());
515 }
516
517 let mut sorted_vars: Vec<_> = env_vars.into_iter().collect();
518 sorted_vars.sort();
519
520 println!(
521 "š§ Found {} unique environment variables.",
522 sorted_vars.len()
523 );
524
525 let turbo_content = std::fs::read_to_string(&turbo_path)?;
526 let mut turbo_json: serde_json::Value = serde_json::from_str(&turbo_content)?;
527
528 if let Some(tasks_obj) = turbo_json.get_mut("tasks").and_then(|t| t.as_object_mut()) {
529 for task in tasks {
530 if let Some(task_config) = tasks_obj.get_mut(&task).and_then(|t| t.as_object_mut()) {
531 println!(" ā
Updating task: {task}");
532 task_config.insert("env".to_string(), serde_json::to_value(&sorted_vars)?);
533 }
534 }
535 }
536
537 if args.dry_run {
538 println!("\nš DRY RUN - Preview of updated turbo.json tasks:");
539 if let Some(tasks_obj) = turbo_json.get_mut("tasks") {
540 println!("{}", serde_json::to_string_pretty(tasks_obj)?);
541 }
542 } else {
543 let updated_content = serde_json::to_string_pretty(&turbo_json)? + "\n";
544 std::fs::write(&turbo_path, updated_content)?;
545 println!("\nā
Successfully updated turbo.json!");
546 }
547
548 Ok(())
549}
550
551fn run_kill_ports(args: KillPortsArgs) -> Result<()> {
552 let mut ports = Vec::new();
553 for target in args.targets {
554 let target_str: &str = ⌖
555 if target_str.contains("..") {
556 let parts: Vec<&str> = target_str.split("..").collect();
557 if parts.len() == 2 {
558 let start: u16 = parts[0].parse().context("Invalid start port")?;
559 let end: u16 = parts[1].parse().context("Invalid end port")?;
560 for p in start..=end {
561 ports.push(p);
562 }
563 }
564 } else {
565 let p: u16 = target_str.parse().context("Invalid port")?;
566 ports.push(p);
567 }
568 }
569
570 if ports.is_empty() {
571 println!("No ports specified.");
572 return Ok(());
573 }
574
575 println!("š Searching for processes on ports: {ports:?}...");
576
577 let ports_str = ports
578 .iter()
579 .map(|p: &u16| p.to_string())
580 .collect::<Vec<_>>()
581 .join(",");
582 let output = Command::new("lsof")
583 .args([
584 "-i",
585 &format!("TCP:{ports_str}"),
586 "-sTCP:LISTEN",
587 "-P",
588 "-n",
589 "-t",
590 ])
591 .output()
592 .context("Failed to run lsof. Is it installed?")?;
593
594 let pids_raw = String::from_utf8_lossy(&output.stdout);
595 let pids: Vec<&str> = pids_raw.lines().filter(|l| !l.trim().is_empty()).collect();
596
597 if pids.is_empty() {
598 println!("ā
No processes found listening on these ports.");
599 return Ok(());
600 }
601
602 println!("ā ļø Found {} process(es):", pids.len());
603 for pid in &pids {
604 let info = Command::new("ps")
605 .args(["-p", pid, "-o", "comm="])
606 .output()
607 .ok();
608 let comm = info.map_or_else(
609 || "unknown".into(),
610 |o| String::from_utf8_lossy(&o.stdout).trim().to_string(),
611 );
612 println!(" - PID {pid} ({comm})");
613 }
614
615 if !args.yes && !args.force {
616 print!("\nTerminate these processes? [y/N]: ");
617 io::stdout().flush()?;
618 let mut input = String::new();
619 io::stdin().read_line(&mut input)?;
620 if !input.trim().eq_ignore_ascii_case("y") {
621 println!("Aborted.");
622 return Ok(());
623 }
624 }
625
626 let signal = if args.force { "-9" } else { "-15" };
627 let mut success = 0;
628 let mut failed = 0;
629
630 for pid in pids {
631 let status = Command::new("kill").args([signal, pid]).status();
632
633 if status.is_ok_and(|s| s.success()) {
634 success += 1;
635 } else {
636 failed += 1;
637 }
638 }
639
640 println!("\nSummary:");
641 println!(" ā
Successfully signaled {success} process(es).");
642 if failed > 0 {
643 println!(" ā Failed to signal {failed} process(es). (Try with sudo?)");
644 }
645
646 Ok(())
647}