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