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