1use crate::core::config_loader::ConfigLoader;
15use crate::output::{OutputFormat, print_output};
16use anyhow::{Context, Result, bail};
17use clap::Args;
18use std::io::IsTerminal;
19use std::path::PathBuf;
20
21const CONSOLE: &str = "https://console.cloud.google.com";
22const SETUP_DOCS: &str = "https://github.com/debanjanbasu/grr-cli/blob/main/docs/gcp-setup.md";
23
24const SERVICES: &[(&str, &str)] = &[
28 ("gmail.googleapis.com", "Gmail"),
29 ("calendar-json.googleapis.com", "Calendar"),
30 ("drive.googleapis.com", "Drive"),
31 ("people.googleapis.com", "Contacts"),
32 ("chat.googleapis.com", "Chat"),
33 ("forms.googleapis.com", "Forms"),
34 ("tasks.googleapis.com", "Tasks"),
35 ("docs.googleapis.com", "Docs"),
36 ("sheets.googleapis.com", "Sheets"),
37 ("slides.googleapis.com", "Slides"),
38 ("script.googleapis.com", "Apps Script"),
39 ("analyticsadmin.googleapis.com", "Analytics Admin"),
40 ("analyticsdata.googleapis.com", "Analytics Data"),
41 ("searchconsole.googleapis.com", "Search Console"),
42];
43
44#[derive(Args, Debug, Clone)]
45pub struct SetupArgs {
46 #[arg(long)]
49 pub client_id: Option<String>,
50
51 #[arg(long)]
53 pub client_secret: Option<String>,
54
55 #[arg(long)]
58 pub print_only: bool,
59
60 #[arg(long)]
62 pub force: bool,
63
64 #[arg(long)]
66 pub enable_apis: bool,
67
68 #[arg(short, long, value_enum, default_value = "json")]
70 pub format: OutputFormat,
71}
72
73fn validate_client_id(id: &str) -> Result<()> {
76 let id = id.trim();
77 if id.is_empty() {
78 bail!("client id is empty");
79 }
80 if !id.ends_with(".apps.googleusercontent.com") {
81 bail!(
82 "that does not look like a Google OAuth client id.\n\
83 Expected something ending in .apps.googleusercontent.com, got:\n {id}\n\
84 Copy it from Google Cloud console -> Google Auth Platform -> Clients."
85 );
86 }
87 if !id.contains('.') {
88 bail!("client id looks truncated: {id}");
89 }
90 Ok(())
91}
92
93fn validate_client_secret(secret: &str) -> Result<()> {
94 if secret.trim().is_empty() {
96 bail!("client secret is empty");
97 }
98 if secret.trim().len() < 10 {
99 bail!(
100 "that client secret looks truncated ({} chars).\n\
101 Copy the whole value from the OAuth client page.",
102 secret.trim().len()
103 );
104 }
105 Ok(())
106}
107
108fn config_path() -> Result<PathBuf> {
109 Ok(ConfigLoader::config_path()?)
110}
111
112fn toml_escape(value: &str) -> String {
114 value.replace('\\', r"\\").replace('"', "\\\"")
115}
116
117const CONFIG_TEMPLATE: &str = include_str!("../../config.toml.example");
124
125fn render_config(client_id: &str, client_secret: &str) -> String {
137 CONFIG_TEMPLATE
138 .replacen(
139 "client_id = \"\"",
140 &format!("client_id = \"{}\"", toml_escape(client_id)),
141 1,
142 )
143 .replacen(
144 "# client_secret = \"\"",
145 &format!("client_secret = \"{}\"", toml_escape(client_secret)),
146 1,
147 )
148}
149
150fn instructions() -> String {
151 let mut out = String::new();
152 out.push_str("grr needs one Google OAuth client. Three steps, about five minutes:\n\n");
153 out.push_str(&format!(
154 "1. Create a project\n {CONSOLE}/projectcreate\n\n"
155 ));
156 out.push_str(&format!(
157 "2. Enable the APIs (one command if you have gcloud):\n gcloud services enable {}\n\n",
158 SERVICES
159 .iter()
160 .map(|(s, _)| *s)
161 .collect::<Vec<_>>()
162 .join(" ")
163 ));
164 out.push_str(&format!(
165 "3. Create the client\n {CONSOLE}/auth/clients/create\n Application type: Desktop app\n Then copy the Client ID and Client secret.\n\n"
166 ));
167 out.push_str("Full walkthrough: ");
168 out.push_str(SETUP_DOCS);
169 out.push('\n');
170 out
171}
172
173pub async fn handle_setup(args: SetupArgs) -> Result<()> {
174 let path = config_path()?;
175 let recipe = instructions();
176
177 if args.print_only {
178 print_output(
179 &serde_json::json!({
180 "instructions": recipe,
181 "config_path": path.display().to_string(),
182 "wrote_config": false,
183 "apis": SERVICES.iter().map(|(s, n)| serde_json::json!({"service": s, "label": n})).collect::<Vec<_>>(),
184 }),
185 args.format,
186 )?;
187 return Ok(());
188 }
189
190 let interactive = std::io::stdin().is_terminal();
192
193 let client_id = match args.client_id {
194 Some(id) => id,
195 None => {
196 if !interactive {
197 bail!(
198 "--client-id is required when stdin is not a terminal (CI, agents, pipes).\n\n{recipe}"
199 );
200 }
201 prompt("Client ID (…apps.googleusercontent.com): ").await?
202 }
203 };
204 validate_client_id(&client_id)?;
205
206 let client_secret = match args.client_secret {
207 Some(secret) => secret,
208 None => {
209 if !interactive {
210 bail!(
211 "--client-secret is required when stdin is not a terminal (CI, agents, pipes).\n\n{recipe}"
212 );
213 }
214 prompt("Client secret: ").await?
215 }
216 };
217 validate_client_secret(&client_secret)?;
218
219 if path.exists() && !args.force {
220 bail!(
221 "{} already exists; it will not be overwritten.\n\
222 Rerun with --force to replace it.",
223 path.display()
224 );
225 }
226
227 if let Some(parent) = path.parent() {
228 std::fs::create_dir_all(parent)
229 .with_context(|| format!("creating {}", parent.display()))?;
230 }
231 let contents = render_config(client_id.trim(), client_secret.trim());
232 std::fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))?;
233
234 #[cfg(unix)]
235 {
236 use std::os::unix::fs::PermissionsExt;
237 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
238 }
239
240 let mut apis_enabled = false;
241 if args.enable_apis {
242 match enable_apis_via_gcloud().await {
243 Ok(()) => apis_enabled = true,
244 Err(e) => eprintln!("warning: could not enable APIs automatically: {e}"),
245 }
246 }
247
248 print_output(
249 &serde_json::json!({
250 "wrote_config": true,
251 "config_path": path.display().to_string(),
252 "client_id": client_id.trim(),
253 "apis_enabled": apis_enabled,
254 "next_step": "grr auth login",
255 }),
256 args.format,
257 )?;
258
259 Ok(())
260}
261
262async fn prompt(label: &str) -> Result<String> {
268 use std::io::Write;
269 eprint!("{label}");
270 std::io::stderr().flush().ok();
271 let mut line = String::new();
272 let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
273 tokio::io::AsyncBufReadExt::read_line(&mut stdin, &mut line).await?;
274 Ok(line.trim().to_owned())
275}
276
277async fn enable_apis_via_gcloud() -> Result<()> {
283 let services = SERVICES
284 .iter()
285 .map(|(s, _)| *s)
286 .collect::<Vec<_>>()
287 .join(" ");
288
289 let status = tokio::process::Command::new("gcloud")
290 .args(["services", "enable", &services])
291 .status()
292 .await
293 .context("gcloud is not on PATH (see https://cloud.google.com/sdk/docs/install)")?;
294
295 if !status.success() {
296 bail!("`gcloud services enable` exited with {status}");
297 }
298 Ok(())
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn accepts_a_real_looking_client_id() {
307 assert!(validate_client_id("123-abc.apps.googleusercontent.com").is_ok());
308 assert!(validate_client_id(" 123-abc.apps.googleusercontent.com ").is_ok());
309 }
310
311 #[test]
312 fn rejects_a_wrong_or_truncated_client_id() {
313 assert!(validate_client_id("123-abc").is_err());
315 assert!(validate_client_id("123-abc.googleusercontent.com").is_err());
316 assert!(validate_client_id("").is_err());
317 assert!(validate_client_id(" ").is_err());
318 }
319
320 #[test]
321 fn rejects_a_truncated_client_secret() {
322 assert!(validate_client_secret("GOCSPX-abc123def456").is_ok());
323 assert!(validate_client_secret("short").is_err());
324 assert!(validate_client_secret(" ").is_err());
325 }
326
327 #[test]
328 fn escapes_quotes_when_writing_toml() {
329 assert_eq!(toml_escape(r#"a"b\c"#), r#"a\"b\\c"#);
330 }
331
332 #[test]
333 fn rendered_config_mirrors_the_example_with_the_client_filled_in() {
334 let rendered = render_config("123-abc.apps.googleusercontent.com", "GOCSPX-secret");
335 assert!(rendered.contains("[oauth]"));
337 assert!(rendered.contains("client_id = \"123-abc.apps.googleusercontent.com\""));
338 assert!(rendered.contains("client_secret = \"GOCSPX-secret\""));
339 assert!(
340 rendered.contains("[systemone]"),
341 "the commented systemone block must ride along"
342 );
343 assert!(rendered.contains("GRR_CONFIG_PATH"));
344 assert!(!rendered.contains("client_id = \"\""));
346 assert!(!rendered.contains("# client_secret = \"\""));
347 }
348
349 #[test]
350 fn instructions_cover_every_service() {
351 let text = instructions();
352 for (service, _) in SERVICES {
353 assert!(text.contains(service), "missing {service}");
354 }
355 assert!(text.contains("Desktop app"));
356 }
357
358 #[test]
359 fn writes_where_the_loader_reads() {
360 assert_eq!(config_path().unwrap(), ConfigLoader::config_path().unwrap());
363 }
364}