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    hint: String,
85    secret_name: String,
86    secret_reference: String,
87    private_key_secret_name: Option<String>,
88    private_key_secret_reference: Option<String>,
89    rest: Option<RestSetup>,
90    delegated: Option<DelegatedSetup>,
91}
92
93struct RestSetup {
94    command_name: String,
95    base_url: String,
96    method_default: String,
97    auth: RestAuthSetup,
98}
99
100enum RestAuthSetup {
101    Bearer,
102    GitHubApp,
103    OAuth,
104}
105
106struct DelegatedSetup {
107    command_name: String,
108    program: String,
109    env_var: String,
110    check_args: Vec<String>,
111}
112
113fn prompt_service_setup() -> Result<ServiceSetup, ViaError> {
114    let service_name = prompt_required("Service name", None)?;
115    let hint = prompt_optional("Example command hint")?;
116    let secret_name = prompt_required("Secret name in via config", Some("token"))?;
117    let secret_reference = prompt_secret_reference()?;
118    let mode = prompt_service_mode()?;
119    let rest = prompt_optional_rest_setup(mode)?;
120    let (private_key_secret_name, private_key_secret_reference) =
121        prompt_optional_private_key(rest.as_ref())?;
122    let delegated = prompt_optional_delegated_setup(mode)?;
123
124    Ok(ServiceSetup {
125        service_name,
126        hint,
127        secret_name,
128        secret_reference,
129        private_key_secret_name,
130        private_key_secret_reference,
131        rest,
132        delegated,
133    })
134}
135
136fn prompt_service_mode() -> Result<usize, ViaError> {
137    prompt_choice(
138        "How should via run this service?",
139        &["REST API", "Trusted CLI", "Both"],
140        1,
141    )
142}
143
144fn prompt_optional_rest_setup(mode: usize) -> Result<Option<RestSetup>, ViaError> {
145    if mode_uses_rest(mode) {
146        Ok(Some(prompt_rest_setup()?))
147    } else {
148        Ok(None)
149    }
150}
151
152fn prompt_optional_private_key(
153    rest: Option<&RestSetup>,
154) -> Result<(Option<String>, Option<String>), ViaError> {
155    if rest.is_some_and(rest_uses_github_app) {
156        prompt_private_key_secret()
157    } else {
158        Ok((None, None))
159    }
160}
161
162fn prompt_optional_delegated_setup(mode: usize) -> Result<Option<DelegatedSetup>, ViaError> {
163    if mode_uses_delegated(mode) {
164        Ok(Some(prompt_delegated_setup()?))
165    } else {
166        Ok(None)
167    }
168}
169
170fn mode_uses_rest(mode: usize) -> bool {
171    mode == 1 || mode == 3
172}
173
174fn mode_uses_delegated(mode: usize) -> bool {
175    mode == 2 || mode == 3
176}
177
178fn rest_uses_github_app(rest: &RestSetup) -> bool {
179    matches!(rest.auth, RestAuthSetup::GitHubApp)
180}
181
182fn prompt_private_key_secret() -> Result<(Option<String>, Option<String>), ViaError> {
183    println!();
184    println!("GitHub App private key");
185    let name = prompt_required("Private key secret name in via config", Some("private_key"))?;
186    let reference = prompt_secret_reference()?;
187    Ok((Some(name), Some(reference)))
188}
189
190fn prompt_secret_reference() -> Result<String, ViaError> {
191    loop {
192        println!("1Password secret reference:");
193        println!("  Example: op://Private/Service/token");
194        let value = prompt_required("Reference", None)?;
195        if value.starts_with("op://") {
196            return Ok(value);
197        }
198        println!("Secret references must start with `op://`.");
199    }
200}
201
202fn prompt_rest_setup() -> Result<RestSetup, ViaError> {
203    println!();
204    println!("REST API capability");
205    let (command_name, base_url, method_default) = prompt_rest_fields()?;
206    let auth = prompt_rest_auth_setup()?;
207
208    Ok(RestSetup {
209        command_name,
210        base_url,
211        method_default,
212        auth,
213    })
214}
215
216fn prompt_rest_fields() -> Result<(String, String, String), ViaError> {
217    Ok((
218        prompt_required("Capability name", Some("api"))?,
219        prompt_required("Base URL", None)?,
220        prompt_required("Default HTTP method", Some("GET"))?,
221    ))
222}
223
224fn prompt_rest_auth_setup() -> Result<RestAuthSetup, ViaError> {
225    rest_auth_setup_from_choice(prompt_choice(
226        "How should REST authenticate?",
227        &[
228            "Bearer token",
229            "GitHub App credential bundle",
230            "OAuth credential bundle (prefer client_credentials for bots)",
231        ],
232        1,
233    )?)
234}
235
236fn rest_auth_setup_from_choice(choice: usize) -> Result<RestAuthSetup, ViaError> {
237    match choice {
238        1 => Ok(RestAuthSetup::Bearer),
239        2 => Ok(RestAuthSetup::GitHubApp),
240        3 => Ok(RestAuthSetup::OAuth),
241        _ => Err(ViaError::InvalidConfig(format!(
242            "unsupported REST auth choice {choice}"
243        ))),
244    }
245}
246
247fn prompt_delegated_setup() -> Result<DelegatedSetup, ViaError> {
248    println!();
249    println!("Trusted CLI capability");
250    let program = prompt_required("Program", None)?;
251    let default_command = program.clone();
252    let command_name = prompt_required("Capability name", Some(&default_command))?;
253    let env_var = prompt_required("Environment variable to inject", Some("TOKEN"))?;
254    let check = prompt_required("Check command args", Some("--version"))?;
255
256    Ok(DelegatedSetup {
257        command_name,
258        program,
259        env_var,
260        check_args: split_args(&check),
261    })
262}
263
264fn prompt_choice(prompt: &str, choices: &[&str], default: usize) -> Result<usize, ViaError> {
265    loop {
266        println!("{prompt}");
267        for (index, choice) in choices.iter().enumerate() {
268            println!("  {}. {choice}", index + 1);
269        }
270
271        let raw = prompt_optional(&format!("Choice [{default}]"))?;
272        let choice = if raw.is_empty() {
273            default
274        } else {
275            match raw.parse::<usize>() {
276                Ok(choice) => choice,
277                Err(_) => {
278                    println!("Enter a number from 1 to {}.", choices.len());
279                    continue;
280                }
281            }
282        };
283
284        if (1..=choices.len()).contains(&choice) {
285            return Ok(choice);
286        }
287        println!("Enter a number from 1 to {}.", choices.len());
288    }
289}
290
291fn prompt_required(prompt: &str, default: Option<&str>) -> Result<String, ViaError> {
292    loop {
293        let label = match default {
294            Some(default) => format!("{prompt} [{default}]"),
295            None => prompt.to_owned(),
296        };
297        let value = prompt_optional(&label)?;
298        if !value.is_empty() {
299            return Ok(value);
300        }
301        if let Some(default) = default {
302            return Ok(default.to_owned());
303        }
304        println!("This value is required.");
305    }
306}
307
308fn prompt_optional(prompt: &str) -> Result<String, ViaError> {
309    print!("{prompt}: ");
310    io::stdout().flush()?;
311
312    let mut value = String::new();
313    io::stdin().read_line(&mut value)?;
314    Ok(value.trim().to_owned())
315}
316
317fn build_service_config(setup: ServiceSetup) -> String {
318    let mut output = String::new();
319    output.push_str("version = 1\n\n");
320    output.push_str("[providers.onepassword]\n");
321    output.push_str("type = \"1password\"\n");
322    output.push_str("cache = \"daemon\"\n\n");
323    output.push_str(&format!("[services.{}]\n", toml_key(&setup.service_name)));
324    output.push_str(&format!(
325        "description = {}\n",
326        toml_string(&format!("{} access", setup.service_name))
327    ));
328    if !setup.hint.is_empty() {
329        output.push_str(&format!("hint = {}\n", toml_string(&setup.hint)));
330    }
331    output.push_str("provider = \"onepassword\"\n\n");
332    output.push_str(&format!(
333        "[services.{}.secrets]\n",
334        toml_key(&setup.service_name)
335    ));
336    output.push_str(&format!(
337        "{} = {}\n\n",
338        toml_key(&setup.secret_name),
339        toml_string(&setup.secret_reference)
340    ));
341    if let (Some(name), Some(reference)) = (
342        &setup.private_key_secret_name,
343        &setup.private_key_secret_reference,
344    ) {
345        output.truncate(output.trim_end_matches('\n').len());
346        output.push('\n');
347        output.push_str(&format!(
348            "{} = {}\n\n",
349            toml_key(name),
350            toml_string(reference)
351        ));
352    }
353
354    if let Some(rest) = setup.rest {
355        output.push_str(&format!(
356            "[services.{}.commands.{}]\n",
357            toml_key(&setup.service_name),
358            toml_key(&rest.command_name)
359        ));
360        output
361            .push_str("description = \"Call the configured REST API. Prefer this for agents.\"\n");
362        output.push_str("mode = \"rest\"\n");
363        output.push_str(&format!("base_url = {}\n", toml_string(&rest.base_url)));
364        output.push_str(&format!(
365            "method_default = {}\n\n",
366            toml_string(&rest.method_default)
367        ));
368        output.push_str(&format!(
369            "[services.{}.commands.{}.auth]\n",
370            toml_key(&setup.service_name),
371            toml_key(&rest.command_name)
372        ));
373        match rest.auth {
374            RestAuthSetup::Bearer => {
375                output.push_str("type = \"bearer\"\n");
376                output.push_str(&format!("secret = {}\n\n", toml_string(&setup.secret_name)));
377            }
378            RestAuthSetup::GitHubApp => {
379                let private_key = setup
380                    .private_key_secret_name
381                    .as_deref()
382                    .unwrap_or("private_key");
383                output.push_str("type = \"github_app\"\n");
384                output.push_str(&format!(
385                    "credential = {}\n",
386                    toml_string(&setup.secret_name)
387                ));
388                output.push_str(&format!("private_key = {}\n\n", toml_string(private_key)));
389            }
390            RestAuthSetup::OAuth => {
391                output.push_str("type = \"oauth\"\n");
392                output.push_str(&format!(
393                    "credential = {}\n\n",
394                    toml_string(&setup.secret_name)
395                ));
396            }
397        }
398    }
399
400    if let Some(delegated) = setup.delegated {
401        output.push_str(&format!(
402            "[services.{}.commands.{}]\n",
403            toml_key(&setup.service_name),
404            toml_key(&delegated.command_name)
405        ));
406        output
407            .push_str("description = \"Run the configured trusted CLI with a secret injected.\"\n");
408        output.push_str("mode = \"delegated\"\n");
409        output.push_str(&format!("program = {}\n", toml_string(&delegated.program)));
410        output.push_str(&format!(
411            "check = {}\n\n",
412            toml_array(&delegated.check_args)
413        ));
414        output.push_str(&format!(
415            "[services.{}.commands.{}.inject.env.{}]\n",
416            toml_key(&setup.service_name),
417            toml_key(&delegated.command_name),
418            toml_key(&delegated.env_var)
419        ));
420        output.push_str(&format!("secret = {}\n", toml_string(&setup.secret_name)));
421    }
422
423    output
424}
425
426fn empty_config() -> &'static str {
427    r#"version = 1
428
429[providers.onepassword]
430type = "1password"
431cache = "daemon"
432
433"#
434}
435
436fn split_args(value: &str) -> Vec<String> {
437    value.split_whitespace().map(str::to_owned).collect()
438}
439
440fn toml_key(value: &str) -> String {
441    toml_string(value)
442}
443
444fn toml_array(values: &[String]) -> String {
445    let values = values
446        .iter()
447        .map(|value| toml_string(value))
448        .collect::<Vec<_>>()
449        .join(", ");
450    format!("[{values}]")
451}
452
453fn toml_string(value: &str) -> String {
454    let mut escaped = String::new();
455    for character in value.chars() {
456        match character {
457            '\\' => escaped.push_str("\\\\"),
458            '"' => escaped.push_str("\\\""),
459            '\n' => escaped.push_str("\\n"),
460            '\r' => escaped.push_str("\\r"),
461            '\t' => escaped.push_str("\\t"),
462            other => escaped.push(other),
463        }
464    }
465    format!("\"{escaped}\"")
466}
467
468fn print_missing_config(path: &Path) {
469    println!("No via config found at:");
470    println!("  {}", path.display());
471    println!();
472    println!("Human setup:");
473    println!("  Run `via config` in an interactive terminal to create one.");
474    println!();
475    println!("Agent guidance:");
476    println!("  Ask the user to run `via config`, then rerun `via config doctor`.");
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn builds_generic_rest_config() {
485        let config = build_service_config(ServiceSetup {
486            service_name: "gitlab".to_owned(),
487            hint: "via gitlab api /projects".to_owned(),
488            secret_name: "token".to_owned(),
489            secret_reference: "op://Private/GitLab/token".to_owned(),
490            private_key_secret_name: None,
491            private_key_secret_reference: None,
492            rest: Some(RestSetup {
493                command_name: "api".to_owned(),
494                base_url: "https://gitlab.example.com/api/v4".to_owned(),
495                method_default: "GET".to_owned(),
496                auth: RestAuthSetup::Bearer,
497            }),
498            delegated: None,
499        });
500
501        assert!(config.contains("[services.\"gitlab\"]"));
502        assert!(config.contains("hint = \"via gitlab api /projects\""));
503        assert!(config.contains("cache = \"daemon\""));
504        assert!(config.contains("base_url = \"https://gitlab.example.com/api/v4\""));
505        assert!(Config::from_toml_str(&config).is_ok());
506    }
507
508    #[test]
509    fn builds_github_app_rest_config() {
510        let config = build_service_config(ServiceSetup {
511            service_name: "github".to_owned(),
512            hint: String::new(),
513            secret_name: "app".to_owned(),
514            secret_reference: "op://Private/Example GitHub App/metadata".to_owned(),
515            private_key_secret_name: Some("private_key".to_owned()),
516            private_key_secret_reference: Some(
517                "op://Private/Example GitHub App/github-app.private-key.pem".to_owned(),
518            ),
519            rest: Some(RestSetup {
520                command_name: "api".to_owned(),
521                base_url: "https://api.github.com".to_owned(),
522                method_default: "GET".to_owned(),
523                auth: RestAuthSetup::GitHubApp,
524            }),
525            delegated: None,
526        });
527
528        assert!(config.contains("type = \"github_app\""));
529        assert!(config.contains("cache = \"daemon\""));
530        assert!(config.contains("credential = \"app\""));
531        assert!(config.contains("private_key = \"private_key\""));
532        assert!(Config::from_toml_str(&config).is_ok());
533    }
534
535    #[test]
536    fn builds_oauth_rest_config() {
537        let config = build_service_config(ServiceSetup {
538            service_name: "linear".to_owned(),
539            hint: "via linear api POST /graphql --json '{\"query\":\"{ viewer { id name } }\"}'"
540                .to_owned(),
541            secret_name: "oauth".to_owned(),
542            secret_reference: "op://Private/Linear/oauth".to_owned(),
543            private_key_secret_name: None,
544            private_key_secret_reference: None,
545            rest: Some(RestSetup {
546                command_name: "api".to_owned(),
547                base_url: "https://api.linear.app".to_owned(),
548                method_default: "GET".to_owned(),
549                auth: RestAuthSetup::OAuth,
550            }),
551            delegated: None,
552        });
553
554        assert!(config.contains("type = \"oauth\""));
555        assert!(config.contains(
556            "hint = \"via linear api POST /graphql --json '{\\\"query\\\":\\\"{ viewer { id name } }\\\"}'\""
557        ));
558        assert!(config.contains("credential = \"oauth\""));
559        assert!(Config::from_toml_str(&config).is_ok());
560    }
561
562    #[test]
563    fn maps_rest_auth_setup_choices() {
564        assert!(matches!(
565            rest_auth_setup_from_choice(1).unwrap(),
566            RestAuthSetup::Bearer
567        ));
568        assert!(matches!(
569            rest_auth_setup_from_choice(2).unwrap(),
570            RestAuthSetup::GitHubApp
571        ));
572        assert!(matches!(
573            rest_auth_setup_from_choice(3).unwrap(),
574            RestAuthSetup::OAuth
575        ));
576        assert!(rest_auth_setup_from_choice(4).is_err());
577    }
578
579    #[test]
580    fn builds_generic_delegated_config() {
581        let config = build_service_config(ServiceSetup {
582            service_name: "deploy tool".to_owned(),
583            hint: String::new(),
584            secret_name: "api token".to_owned(),
585            secret_reference: "op://Private/Deploy/token".to_owned(),
586            private_key_secret_name: None,
587            private_key_secret_reference: None,
588            rest: None,
589            delegated: Some(DelegatedSetup {
590                command_name: "cli".to_owned(),
591                program: "deployctl".to_owned(),
592                env_var: "DEPLOY_TOKEN".to_owned(),
593                check_args: vec!["--version".to_owned()],
594            }),
595        });
596
597        assert!(config.contains("[services.\"deploy tool\"]"));
598        assert!(config
599            .contains("[services.\"deploy tool\".commands.\"cli\".inject.env.\"DEPLOY_TOKEN\"]"));
600        assert!(Config::from_toml_str(&config).is_ok());
601    }
602
603    #[test]
604    fn escapes_toml_strings() {
605        assert_eq!(toml_string("a\"b\\c"), "\"a\\\"b\\\\c\"");
606    }
607}