Skip to main content

resq_cli/commands/
dev.rs

1/*
2 * Copyright 2026 ResQ
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! ResQ Dev commands — Repository and development utilities.
18
19use 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/// Arguments for the 'dev' command.
28#[derive(Parser, Debug)]
29pub struct DevArgs {
30    /// Dev subcommand to execute
31    #[command(subcommand)]
32    pub command: DevCommands,
33}
34
35/// Developer subcommands.
36#[derive(Subcommand, Debug)]
37pub enum DevCommands {
38    /// Kill processes listening on specified ports
39    KillPorts(KillPortsArgs),
40    /// Sync environment variables from .env.example files to turbo.json
41    SyncEnv(SyncEnvArgs),
42    /// Upgrade dependencies across all monorepo silos
43    Upgrade(UpgradeArgs),
44    /// Install git hooks from .git-hooks directory
45    InstallHooks,
46    /// Scaffold a repo-specific `.git-hooks/local-<hook>` file
47    ScaffoldLocalHook(ScaffoldLocalHookArgs),
48}
49
50/// Arguments for the `scaffold-local-hook` command.
51#[derive(Parser, Debug)]
52pub struct ScaffoldLocalHookArgs {
53    /// Project kind. `auto` detects from root marker files
54    /// (Cargo.toml, pyproject.toml, package.json, *.sln, CMakeLists.txt, flake.nix).
55    #[arg(long, default_value = "auto")]
56    pub kind: String,
57
58    /// Which hook to scaffold a local override for (e.g. `pre-push`).
59    #[arg(long, default_value = "pre-push")]
60    pub hook: String,
61
62    /// Overwrite an existing `local-<hook>` file.
63    #[arg(long)]
64    pub force: bool,
65}
66
67/// Arguments for the 'kill-ports' command.
68#[derive(Parser, Debug)]
69pub struct KillPortsArgs {
70    /// Ports or ranges (e.g. 8000 or 8000..8010)
71    #[arg(required = true)]
72    pub targets: Vec<String>,
73
74    /// Use SIGKILL instead of default SIGTERM
75    #[arg(short, long)]
76    pub force: bool,
77
78    /// Skip confirmation prompt
79    #[arg(short, long)]
80    pub yes: bool,
81}
82
83/// Arguments for the 'sync-env' command.
84#[derive(Parser, Debug)]
85pub struct SyncEnvArgs {
86    /// Tasks to update in turbo.json (comma-separated)
87    #[arg(short, long, default_value = "build,dev,start,test")]
88    pub tasks: String,
89
90    /// Preview changes without writing to turbo.json
91    #[arg(short, long)]
92    pub dry_run: bool,
93
94    /// Maximum directory depth to search
95    #[arg(long, default_value_t = 10)]
96    pub max_depth: usize,
97}
98
99/// Arguments for the 'upgrade' command.
100#[derive(Parser, Debug)]
101pub struct UpgradeArgs {
102    /// Specific silo to upgrade (python, rust, js, cpp, csharp, nix, or all)
103    #[arg(default_value = "all")]
104    pub silo: String,
105}
106
107/// Executes the dev command.
108pub 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    // Scaffold any canonical hook the repo doesn't already have. The per-file
123    // `dest.exists()` guard preserves user-edited or repo-pinned content, so
124    // a partial layout (one custom hook, others missing) gets filled in
125    // rather than left half-installed.
126    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        // Permissions are set below by the executable-bit loop that runs
138        // for every file in hooks_dir; no need to chmod again here.
139    }
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    // Configure git to use custom hooks directory
147    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    // Make hooks executable
158    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
185/// Per-kind local-hook templates embedded at compile time.
186/// The outer index is `(kind, hook_name, content)`.
187const 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    // .NET — any .sln/.csproj/.fsproj/.vbproj at the root, case-insensitive.
242    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 = &target;
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}