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
25/// Arguments for the 'dev' command.
26#[derive(Parser, Debug)]
27pub struct DevArgs {
28    /// Dev subcommand to execute
29    #[command(subcommand)]
30    pub command: DevCommands,
31}
32
33/// Developer subcommands.
34#[derive(Subcommand, Debug)]
35pub enum DevCommands {
36    /// Kill processes listening on specified ports
37    KillPorts(KillPortsArgs),
38    /// Sync environment variables from .env.example files to turbo.json
39    SyncEnv(SyncEnvArgs),
40    /// Upgrade dependencies across all monorepo silos
41    Upgrade(UpgradeArgs),
42    /// Install git hooks from .git-hooks directory
43    InstallHooks,
44}
45
46/// Arguments for the 'kill-ports' command.
47#[derive(Parser, Debug)]
48pub struct KillPortsArgs {
49    /// Ports or ranges (e.g. 8000 or 8000..8010)
50    #[arg(required = true)]
51    pub targets: Vec<String>,
52
53    /// Use SIGKILL instead of default SIGTERM
54    #[arg(short, long)]
55    pub force: bool,
56
57    /// Skip confirmation prompt
58    #[arg(short, long)]
59    pub yes: bool,
60}
61
62/// Arguments for the 'sync-env' command.
63#[derive(Parser, Debug)]
64pub struct SyncEnvArgs {
65    /// Tasks to update in turbo.json (comma-separated)
66    #[arg(short, long, default_value = "build,dev,start,test")]
67    pub tasks: String,
68
69    /// Preview changes without writing to turbo.json
70    #[arg(short, long)]
71    pub dry_run: bool,
72
73    /// Maximum directory depth to search
74    #[arg(long, default_value_t = 10)]
75    pub max_depth: usize,
76}
77
78/// Arguments for the 'upgrade' command.
79#[derive(Parser, Debug)]
80pub struct UpgradeArgs {
81    /// Specific silo to upgrade (python, rust, js, cpp, csharp, nix, or all)
82    #[arg(default_value = "all")]
83    pub silo: String,
84}
85
86/// Executes the dev command.
87pub 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    // Configure git to use custom hooks directory
110    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    // Make hooks executable
121    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 = &target;
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}