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 => {
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
128/// Install the canonical git hooks into the current project's `.git-hooks/`.
129///
130/// # Errors
131/// Returns an error if filesystem access or `git config` invocation fails.
132pub fn run_install_hooks_impl() -> Result<()> {
133    let root = crate::utils::find_project_root();
134    let hooks_dir = root.join(".git-hooks");
135
136    // Scaffold any canonical hook the repo doesn't already have. The per-file
137    // `dest.exists()` guard preserves user-edited or repo-pinned content, so
138    // a partial layout (one custom hook, others missing) gets filled in
139    // rather than left half-installed.
140    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        // Permissions are set below by the executable-bit loop that runs
152        // for every file in hooks_dir; no need to chmod again here.
153    }
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    // Configure git to use custom hooks directory
161    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    // Make hooks executable
172    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
199/// Per-kind local-hook templates embedded at compile time.
200/// The outer index is `(kind, hook_name, content)`.
201const 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    // .NET — any .sln/.csproj/.fsproj/.vbproj at the root, case-insensitive.
256    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
278/// Scaffold a repo-specific `.git-hooks/local-<hook>` file from a kind template.
279///
280/// # Errors
281/// Returns an error if the kind is unknown, the template is missing, or
282/// filesystem access fails.
283pub 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 = &target;
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}