Skip to main content

cmdhub_cli/
runner.rs

1use crate::config::Config;
2use anyhow::{Context, Result};
3use cmdhub_shared::{AciCommandContract, CmdHubError, DbAciRecord, RiskLevel};
4use rusqlite::Connection;
5use std::io::{self, Write};
6use std::process::{Command, Stdio};
7
8pub fn get_command_by_path(conn: &Connection, cmd_path: &str) -> Result<AciCommandContract> {
9    let prov = crate::db::provenance_expr(conn);
10    let mut stmt = conn.prepare(&format!(
11        "SELECT \
12            arg.app_id, \
13            app.name, \
14            arg.cmd_path, \
15            arg.node_type, \
16            arg.description, \
17            arg.risk_level, \
18            arg.example_template, \
19            app.os_aliases, \
20            app.install_instructions, \
21            app.popularity, \
22            arg.docker_image, \
23            arg.script_url, \
24            arg.source_url, \
25            {prov} \
26        FROM arguments arg \
27        JOIN apps app ON arg.app_id = app.app_id \
28        WHERE arg.cmd_path = ?1"
29    ))?;
30
31    let record = stmt
32        .query_row([cmd_path], |row| {
33            Ok(DbAciRecord {
34                app_id: row.get(0)?,
35                name: row.get(1)?,
36                cmd_path: row.get(2)?,
37                node_type: row.get(3)?,
38                description: row.get(4)?,
39                risk_level: row.get(5)?,
40                example_template: row.get(6)?,
41                os_aliases: row.get(7)?,
42                install_instructions: row.get(8)?,
43                popularity: row.get(9)?,
44                docker_image: row.get(10)?,
45                script_url: row.get(11)?,
46                source_url: row.get(12)?,
47                provenance: row.get(13)?,
48            })
49        })
50        .context("Command path not found in database")?;
51
52    AciCommandContract::try_from(record).map_err(|e| anyhow::anyhow!(e))
53}
54
55pub fn run_command(
56    config: &Config,
57    conn: &Connection,
58    cmd_path: &str,
59    args: &[String],
60    skip_gating: bool,
61) -> Result<()> {
62    let contract = get_command_by_path(conn, cmd_path)?;
63
64    // Safety Gate
65    if contract.risk_level == RiskLevel::Dangerous && !skip_gating {
66        match config.risk_guard_level.as_str() {
67            "allow" => {
68                // proceed to execution without prompts or errors
69            }
70            "block" => {
71                return Err(anyhow::anyhow!(CmdHubError::ExecutionBlocked {
72                    risk_level: "dangerous".to_string(),
73                    command: contract.cmd_path.clone(),
74                }));
75            }
76            _ => {
77                // "ask"
78                use std::io::IsTerminal;
79                if std::env::var("CMD_TEST").is_ok() || !std::io::stdin().is_terminal() {
80                    return Err(anyhow::anyhow!(CmdHubError::ExecutionBlocked {
81                        risk_level: "dangerous".to_string(),
82                        command: contract.cmd_path.clone(),
83                    }));
84                }
85
86                eprintln!("\x1b[31;1m[WARNING] RISK LEVEL IS DANGEROUS!\x1b[0m");
87                eprintln!("\x1b[31;1mThis command may have destructive side effects, file deletions, or privilege escalations.\x1b[0m");
88                eprintln!("\x1b[31;1mCommand Path: {}\x1b[0m", contract.cmd_path);
89                eprintln!("\x1b[31;1mDescription: {}\x1b[0m", contract.description);
90                eprint!("Are you sure you want to execute this command? (y/yes to confirm): ");
91                io::stderr().flush()?;
92
93                let mut input = String::new();
94                io::stdin()
95                    .read_line(&mut input)
96                    .context("Failed to read user confirmation from standard input")?;
97                let trimmed = input.trim().to_lowercase();
98                if trimmed != "y" && trimmed != "yes" {
99                    return Err(anyhow::anyhow!(CmdHubError::ExecutionBlocked {
100                        risk_level: "dangerous".to_string(),
101                        command: contract.cmd_path.clone(),
102                    }));
103                }
104            }
105        }
106    }
107
108    // Prepare executable
109    let executable = crate::dto::resolve_binary_name(&contract, config);
110
111    // Check if installed
112    if !crate::dto::check_is_installed(&contract, config) {
113        eprintln!(
114            "Warning: command '{}' is not installed locally.",
115            executable
116        );
117        if let Some(install_cmd) = crate::dto::resolve_install_command(&contract, config) {
118            eprintln!("To install, run: {}", install_cmd);
119        }
120    }
121
122    // Spawn the subprocess
123    let mut child = Command::new(&executable)
124        .args(args)
125        .stdin(Stdio::inherit())
126        .stdout(Stdio::inherit())
127        .stderr(Stdio::inherit())
128        .spawn()
129        .with_context(|| format!("Failed to spawn command '{}'", executable))?;
130
131    let exit_status = child
132        .wait()
133        .context("Failed to wait for child process execution")?;
134
135    if !exit_status.success() {
136        if let Some(code) = exit_status.code() {
137            return Err(anyhow::anyhow!("Process exited with status code: {}", code));
138        } else {
139            return Err(anyhow::anyhow!("Process terminated by signal"));
140        }
141    }
142
143    Ok(())
144}