Skip to main content

via/
config_command.rs

1use std::fs;
2use std::io::{self, IsTerminal, Write};
3use std::path::Path;
4
5use crate::cli::ConfigCommand;
6use crate::config::{self, Config};
7use crate::doctor;
8use crate::error::ViaError;
9
10pub fn run(path_override: Option<&Path>, command: ConfigCommand) -> Result<(), ViaError> {
11    let path = config::resolve_path(path_override)?;
12
13    match command {
14        ConfigCommand::Configure => configure(&path),
15        ConfigCommand::Path => {
16            println!("{}", path.display());
17            Ok(())
18        }
19        ConfigCommand::Doctor { service } => {
20            if !path.exists() {
21                print_missing_config(&path);
22                return Err(ViaError::ConfigNotFound(
23                    "run `via config` in an interactive terminal to create one".to_owned(),
24                ));
25            }
26
27            let config = Config::load(Some(&path))?;
28            doctor::run(&config, service.as_deref())
29        }
30    }
31}
32
33fn configure(path: &Path) -> Result<(), ViaError> {
34    if path.exists() {
35        println!("via config: {}", path.display());
36        println!("Run `via config doctor` to check providers, secrets, and delegated tools.");
37        return Ok(());
38    }
39
40    if !io::stdin().is_terminal() {
41        print_missing_config(path);
42        return Err(ViaError::ConfigNotFound(
43            "run `via config` in an interactive terminal to create one".to_owned(),
44        ));
45    }
46
47    println!("No via config found.");
48    println!();
49    println!("via can create one at:");
50    println!("  {}", path.display());
51    println!();
52
53    match prompt_choice(
54        "What do you want to configure?",
55        &[
56            "A service with 1Password",
57            "Empty config",
58            "Print config path only",
59        ],
60        1,
61    )? {
62        1 => write_config(path, &build_service_config(prompt_service_setup()?)),
63        2 => write_config(path, empty_config()),
64        3 => {
65            println!("{}", path.display());
66            Ok(())
67        }
68        _ => unreachable!("prompt_choice only returns listed choices"),
69    }
70}
71
72fn write_config(path: &Path, contents: &str) -> Result<(), ViaError> {
73    if let Some(parent) = path.parent() {
74        fs::create_dir_all(parent)?;
75    }
76    fs::write(path, contents)?;
77    println!("created via config: {}", path.display());
78    println!("Run `via config doctor` to check the setup.");
79    Ok(())
80}
81
82struct ServiceSetup {
83    service_name: String,
84    secret_name: String,
85    secret_reference: String,
86    private_key_secret_name: Option<String>,
87    private_key_secret_reference: Option<String>,
88    rest: Option<RestSetup>,
89    delegated: Option<DelegatedSetup>,
90}
91
92struct RestSetup {
93    command_name: String,
94    base_url: String,
95    method_default: String,
96    auth: RestAuthSetup,
97}
98
99enum RestAuthSetup {
100    Bearer,
101    GitHubApp,
102}
103
104struct DelegatedSetup {
105    command_name: String,
106    program: String,
107    env_var: String,
108    check_args: Vec<String>,
109}
110
111fn prompt_service_setup() -> Result<ServiceSetup, ViaError> {
112    let service_name = prompt_required("Service name", None)?;
113    let secret_name = prompt_required("Secret name in via config", Some("token"))?;
114    let secret_reference = prompt_secret_reference()?;
115
116    let mode = prompt_choice(
117        "How should via run this service?",
118        &["REST API", "Trusted CLI", "Both"],
119        1,
120    )?;
121
122    let rest = if mode == 1 || mode == 3 {
123        Some(prompt_rest_setup()?)
124    } else {
125        None
126    };
127    let (private_key_secret_name, private_key_secret_reference) = if rest
128        .as_ref()
129        .is_some_and(|rest| matches!(rest.auth, RestAuthSetup::GitHubApp))
130    {
131        println!();
132        println!("GitHub App private key");
133        let name = prompt_required("Private key secret name in via config", Some("private_key"))?;
134        let reference = prompt_secret_reference()?;
135        (Some(name), Some(reference))
136    } else {
137        (None, None)
138    };
139    let delegated = if mode == 2 || mode == 3 {
140        Some(prompt_delegated_setup()?)
141    } else {
142        None
143    };
144
145    Ok(ServiceSetup {
146        service_name,
147        secret_name,
148        secret_reference,
149        private_key_secret_name,
150        private_key_secret_reference,
151        rest,
152        delegated,
153    })
154}
155
156fn prompt_secret_reference() -> Result<String, ViaError> {
157    loop {
158        println!("1Password secret reference:");
159        println!("  Example: op://Private/Service/token");
160        let value = prompt_required("Reference", None)?;
161        if value.starts_with("op://") {
162            return Ok(value);
163        }
164        println!("Secret references must start with `op://`.");
165    }
166}
167
168fn prompt_rest_setup() -> Result<RestSetup, ViaError> {
169    println!();
170    println!("REST API capability");
171    let auth = match prompt_choice(
172        "How should REST authenticate?",
173        &["Bearer token", "GitHub App credential bundle"],
174        1,
175    )? {
176        1 => RestAuthSetup::Bearer,
177        2 => RestAuthSetup::GitHubApp,
178        _ => unreachable!("prompt_choice only returns listed choices"),
179    };
180
181    Ok(RestSetup {
182        command_name: prompt_required("Capability name", Some("api"))?,
183        base_url: prompt_required("Base URL", None)?,
184        method_default: prompt_required("Default HTTP method", Some("GET"))?,
185        auth,
186    })
187}
188
189fn prompt_delegated_setup() -> Result<DelegatedSetup, ViaError> {
190    println!();
191    println!("Trusted CLI capability");
192    let program = prompt_required("Program", None)?;
193    let default_command = program.clone();
194    let command_name = prompt_required("Capability name", Some(&default_command))?;
195    let env_var = prompt_required("Environment variable to inject", Some("TOKEN"))?;
196    let check = prompt_required("Check command args", Some("--version"))?;
197
198    Ok(DelegatedSetup {
199        command_name,
200        program,
201        env_var,
202        check_args: split_args(&check),
203    })
204}
205
206fn prompt_choice(prompt: &str, choices: &[&str], default: usize) -> Result<usize, ViaError> {
207    loop {
208        println!("{prompt}");
209        for (index, choice) in choices.iter().enumerate() {
210            println!("  {}. {choice}", index + 1);
211        }
212
213        let raw = prompt_optional(&format!("Choice [{default}]"))?;
214        let choice = if raw.is_empty() {
215            default
216        } else {
217            match raw.parse::<usize>() {
218                Ok(choice) => choice,
219                Err(_) => {
220                    println!("Enter a number from 1 to {}.", choices.len());
221                    continue;
222                }
223            }
224        };
225
226        if (1..=choices.len()).contains(&choice) {
227            return Ok(choice);
228        }
229        println!("Enter a number from 1 to {}.", choices.len());
230    }
231}
232
233fn prompt_required(prompt: &str, default: Option<&str>) -> Result<String, ViaError> {
234    loop {
235        let label = match default {
236            Some(default) => format!("{prompt} [{default}]"),
237            None => prompt.to_owned(),
238        };
239        let value = prompt_optional(&label)?;
240        if !value.is_empty() {
241            return Ok(value);
242        }
243        if let Some(default) = default {
244            return Ok(default.to_owned());
245        }
246        println!("This value is required.");
247    }
248}
249
250fn prompt_optional(prompt: &str) -> Result<String, ViaError> {
251    print!("{prompt}: ");
252    io::stdout().flush()?;
253
254    let mut value = String::new();
255    io::stdin().read_line(&mut value)?;
256    Ok(value.trim().to_owned())
257}
258
259fn build_service_config(setup: ServiceSetup) -> String {
260    let mut output = String::new();
261    output.push_str("version = 1\n\n");
262    output.push_str("[providers.onepassword]\n");
263    output.push_str("type = \"1password\"\n\n");
264    output.push_str(&format!("[services.{}]\n", toml_key(&setup.service_name)));
265    output.push_str(&format!(
266        "description = {}\n",
267        toml_string(&format!("{} access", setup.service_name))
268    ));
269    output.push_str("provider = \"onepassword\"\n\n");
270    output.push_str(&format!(
271        "[services.{}.secrets]\n",
272        toml_key(&setup.service_name)
273    ));
274    output.push_str(&format!(
275        "{} = {}\n\n",
276        toml_key(&setup.secret_name),
277        toml_string(&setup.secret_reference)
278    ));
279    if let (Some(name), Some(reference)) = (
280        &setup.private_key_secret_name,
281        &setup.private_key_secret_reference,
282    ) {
283        output.truncate(output.trim_end_matches('\n').len());
284        output.push('\n');
285        output.push_str(&format!(
286            "{} = {}\n\n",
287            toml_key(name),
288            toml_string(reference)
289        ));
290    }
291
292    if let Some(rest) = setup.rest {
293        output.push_str(&format!(
294            "[services.{}.commands.{}]\n",
295            toml_key(&setup.service_name),
296            toml_key(&rest.command_name)
297        ));
298        output
299            .push_str("description = \"Call the configured REST API. Prefer this for agents.\"\n");
300        output.push_str("mode = \"rest\"\n");
301        output.push_str(&format!("base_url = {}\n", toml_string(&rest.base_url)));
302        output.push_str(&format!(
303            "method_default = {}\n\n",
304            toml_string(&rest.method_default)
305        ));
306        output.push_str(&format!(
307            "[services.{}.commands.{}.auth]\n",
308            toml_key(&setup.service_name),
309            toml_key(&rest.command_name)
310        ));
311        match rest.auth {
312            RestAuthSetup::Bearer => {
313                output.push_str("type = \"bearer\"\n");
314                output.push_str(&format!("secret = {}\n\n", toml_string(&setup.secret_name)));
315            }
316            RestAuthSetup::GitHubApp => {
317                let private_key = setup
318                    .private_key_secret_name
319                    .as_deref()
320                    .unwrap_or("private_key");
321                output.push_str("type = \"github_app\"\n");
322                output.push_str(&format!(
323                    "credential = {}\n",
324                    toml_string(&setup.secret_name)
325                ));
326                output.push_str(&format!("private_key = {}\n\n", toml_string(private_key)));
327            }
328        }
329    }
330
331    if let Some(delegated) = setup.delegated {
332        output.push_str(&format!(
333            "[services.{}.commands.{}]\n",
334            toml_key(&setup.service_name),
335            toml_key(&delegated.command_name)
336        ));
337        output
338            .push_str("description = \"Run the configured trusted CLI with a secret injected.\"\n");
339        output.push_str("mode = \"delegated\"\n");
340        output.push_str(&format!("program = {}\n", toml_string(&delegated.program)));
341        output.push_str(&format!(
342            "check = {}\n\n",
343            toml_array(&delegated.check_args)
344        ));
345        output.push_str(&format!(
346            "[services.{}.commands.{}.inject.env.{}]\n",
347            toml_key(&setup.service_name),
348            toml_key(&delegated.command_name),
349            toml_key(&delegated.env_var)
350        ));
351        output.push_str(&format!("secret = {}\n", toml_string(&setup.secret_name)));
352    }
353
354    output
355}
356
357fn empty_config() -> &'static str {
358    r#"version = 1
359
360[providers.onepassword]
361type = "1password"
362
363"#
364}
365
366fn split_args(value: &str) -> Vec<String> {
367    value.split_whitespace().map(str::to_owned).collect()
368}
369
370fn toml_key(value: &str) -> String {
371    toml_string(value)
372}
373
374fn toml_array(values: &[String]) -> String {
375    let values = values
376        .iter()
377        .map(|value| toml_string(value))
378        .collect::<Vec<_>>()
379        .join(", ");
380    format!("[{values}]")
381}
382
383fn toml_string(value: &str) -> String {
384    let mut escaped = String::new();
385    for character in value.chars() {
386        match character {
387            '\\' => escaped.push_str("\\\\"),
388            '"' => escaped.push_str("\\\""),
389            '\n' => escaped.push_str("\\n"),
390            '\r' => escaped.push_str("\\r"),
391            '\t' => escaped.push_str("\\t"),
392            other => escaped.push(other),
393        }
394    }
395    format!("\"{escaped}\"")
396}
397
398fn print_missing_config(path: &Path) {
399    println!("No via config found at:");
400    println!("  {}", path.display());
401    println!();
402    println!("Human setup:");
403    println!("  Run `via config` in an interactive terminal to create one.");
404    println!();
405    println!("Agent guidance:");
406    println!("  Ask the user to run `via config`, then rerun `via config doctor`.");
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn builds_generic_rest_config() {
415        let config = build_service_config(ServiceSetup {
416            service_name: "gitlab".to_owned(),
417            secret_name: "token".to_owned(),
418            secret_reference: "op://Private/GitLab/token".to_owned(),
419            private_key_secret_name: None,
420            private_key_secret_reference: None,
421            rest: Some(RestSetup {
422                command_name: "api".to_owned(),
423                base_url: "https://gitlab.example.com/api/v4".to_owned(),
424                method_default: "GET".to_owned(),
425                auth: RestAuthSetup::Bearer,
426            }),
427            delegated: None,
428        });
429
430        assert!(config.contains("[services.\"gitlab\"]"));
431        assert!(config.contains("base_url = \"https://gitlab.example.com/api/v4\""));
432        assert!(Config::from_toml_str(&config).is_ok());
433    }
434
435    #[test]
436    fn builds_github_app_rest_config() {
437        let config = build_service_config(ServiceSetup {
438            service_name: "github".to_owned(),
439            secret_name: "app".to_owned(),
440            secret_reference: "op://Shared/Via GitHub App Voltage/text".to_owned(),
441            private_key_secret_name: Some("private_key".to_owned()),
442            private_key_secret_reference: Some(
443                "op://Shared/Via GitHub App Voltage/private-key.pem".to_owned(),
444            ),
445            rest: Some(RestSetup {
446                command_name: "api".to_owned(),
447                base_url: "https://api.github.com".to_owned(),
448                method_default: "GET".to_owned(),
449                auth: RestAuthSetup::GitHubApp,
450            }),
451            delegated: None,
452        });
453
454        assert!(config.contains("type = \"github_app\""));
455        assert!(config.contains("credential = \"app\""));
456        assert!(config.contains("private_key = \"private_key\""));
457        assert!(Config::from_toml_str(&config).is_ok());
458    }
459
460    #[test]
461    fn builds_generic_delegated_config() {
462        let config = build_service_config(ServiceSetup {
463            service_name: "deploy tool".to_owned(),
464            secret_name: "api token".to_owned(),
465            secret_reference: "op://Private/Deploy/token".to_owned(),
466            private_key_secret_name: None,
467            private_key_secret_reference: None,
468            rest: None,
469            delegated: Some(DelegatedSetup {
470                command_name: "cli".to_owned(),
471                program: "deployctl".to_owned(),
472                env_var: "DEPLOY_TOKEN".to_owned(),
473                check_args: vec!["--version".to_owned()],
474            }),
475        });
476
477        assert!(config.contains("[services.\"deploy tool\"]"));
478        assert!(config
479            .contains("[services.\"deploy tool\".commands.\"cli\".inject.env.\"DEPLOY_TOKEN\"]"));
480        assert!(Config::from_toml_str(&config).is_ok());
481    }
482
483    #[test]
484    fn escapes_toml_strings() {
485        assert_eq!(toml_string("a\"b\\c"), "\"a\\\"b\\\\c\"");
486    }
487}