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}