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