Skip to main content

grr_cli/commands/
setup.rs

1//! `grr auth setup` — create and store an OAuth client without leaving the
2//! terminal.
3//!
4//! This replaces the old `scripts/setup-gcp.ps1`, `setup-gcp.sh` and
5//! `set-client-secret.ps1` helpers: the console links, the client-id shape
6//! check and the config write all live here now, so there is one code path
7//! instead of three that drift apart.
8//!
9//! Two audiences:
10//!   - humans following the prompts (default)
11//!   - agents and CI, which pass `--client-id`/`--client-secret` and
12//!     `--print-only` and must never be blocked on an interactive prompt
13
14use 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
24/// Each API grr can talk to: the id the embedded index uses for it
25/// (`discovery::services()` keys, which is also what `grr auth login --service`
26/// takes), the name `gcloud services enable` knows the API by, and the label a
27/// human reads.
28///
29/// One `gcloud services enable` covers them all; a method's scopes decide what
30/// actually authorises. This is the only list — `every_indexed_service_is_here`
31/// derives the id set from the index and fails the build if the two ever
32/// disagree, so a service added to the index cannot be forgotten here. The
33/// order is curated anyway (Gmail first, the surfaces people set up first)
34/// rather than the index's A–Z, so the recipe reads the way someone would type
35/// it.
36///
37/// The `gcloud` name is typed rather than derived: `rootUrl` is the shared
38/// `www.googleapis.com` for Calendar and Drive, and Calendar's API is the
39/// legacy `calendar-json.googleapis.com` — `calendar.googleapis.com` is not an
40/// API `gcloud` can enable.
41const SERVICE_APIS: &[(&str, &str, &str)] = &[
42    ("gmail", "gmail.googleapis.com", "Gmail"),
43    ("calendar", "calendar-json.googleapis.com", "Calendar"),
44    ("drive", "drive.googleapis.com", "Drive"),
45    ("people", "people.googleapis.com", "Contacts"),
46    ("chat", "chat.googleapis.com", "Chat"),
47    ("forms", "forms.googleapis.com", "Forms"),
48    ("tasks", "tasks.googleapis.com", "Tasks"),
49    ("docs", "docs.googleapis.com", "Docs"),
50    ("sheets", "sheets.googleapis.com", "Sheets"),
51    ("slides", "slides.googleapis.com", "Slides"),
52    ("script", "script.googleapis.com", "Apps Script"),
53    (
54        "analyticsadmin",
55        "analyticsadmin.googleapis.com",
56        "Analytics Admin",
57    ),
58    (
59        "analyticsdata",
60        "analyticsdata.googleapis.com",
61        "Analytics Data",
62    ),
63    (
64        "searchconsole",
65        "searchconsole.googleapis.com",
66        "Search Console",
67    ),
68];
69
70#[derive(Args, Debug, Clone)]
71pub struct SetupArgs {
72    /// Google OAuth client id (…apps.googleusercontent.com). Prompted for
73    /// when omitted.
74    #[arg(long)]
75    pub client_id: Option<String>,
76
77    /// Google OAuth client secret. Prompted for when omitted.
78    #[arg(long)]
79    pub client_secret: Option<String>,
80
81    /// Print the instructions and the resolved path, but write nothing.
82    /// Useful for a first look, and for agents that only want the recipe.
83    #[arg(long)]
84    pub print_only: bool,
85
86    /// Overwrite an existing ~/.grr/config.toml instead of refusing.
87    #[arg(long)]
88    pub force: bool,
89
90    /// Try `gcloud services enable` for the APIs when gcloud is on PATH.
91    #[arg(long)]
92    pub enable_apis: bool,
93
94    /// Output format
95    #[arg(short, long, value_enum, default_value = "json")]
96    pub format: OutputFormat,
97}
98
99/// Shape check: Google's client ids always end in this suffix, and a
100/// truncated paste is the single most common setup mistake.
101fn validate_client_id(id: &str) -> Result<()> {
102    let id = id.trim();
103    if id.is_empty() {
104        bail!("client id is empty");
105    }
106    if !id.ends_with(".apps.googleusercontent.com") {
107        bail!(
108            "that does not look like a Google OAuth client id.\n\
109             Expected something ending in .apps.googleusercontent.com, got:\n  {id}\n\
110             Copy it from Google Cloud console -> Google Auth Platform -> Clients."
111        );
112    }
113    if !id.contains('.') {
114        bail!("client id looks truncated: {id}");
115    }
116    Ok(())
117}
118
119fn validate_client_secret(secret: &str) -> Result<()> {
120    // Google desktop-app secrets are the short `GOCSPX-…` form.
121    if secret.trim().is_empty() {
122        bail!("client secret is empty");
123    }
124    if secret.trim().len() < 10 {
125        bail!(
126            "that client secret looks truncated ({} chars).\n\
127             Copy the whole value from the OAuth client page.",
128            secret.trim().len()
129        );
130    }
131    Ok(())
132}
133
134fn config_path() -> Result<PathBuf> {
135    Ok(ConfigLoader::config_path()?)
136}
137
138/// Escape a value for a TOML basic string.
139fn toml_escape(value: &str) -> String {
140    value.replace('\\', r"\\").replace('"', "\\\"")
141}
142
143/// The config template, taken verbatim from the shipped example so the two
144/// can never drift: `config.toml.example` is the single source of the full
145/// shape (the `[oauth]` block, the commented `[systemone]` block, the
146/// resolution-order note), and setup only fills in the client values. The
147/// example is tracked and committed, so it is inside the crate package and
148/// this `include_str!` always resolves.
149const CONFIG_TEMPLATE: &str = include_str!("../../config.toml.example");
150
151/// Fill the client id/secret into the example template.
152///
153/// This is deliberately not a hand-built TOML document: mirroring the
154/// example keeps every explanatory comment and the `[systemone]` block in
155/// the file the user actually reads, and a future example edit propagates.
156///
157/// An existing config is never rewritten in place — `handle_setup` refuses
158/// without `--force`, and nothing else touches the file. That is on purpose:
159/// silent rewrites are the surprise-write class of bug this codebase avoids,
160/// and a config missing `[systemone]` changes nothing because that section's
161/// defaults apply (see `SystemOneConfig`).
162fn render_config(client_id: &str, client_secret: &str) -> String {
163    CONFIG_TEMPLATE
164        .replacen(
165            "client_id = \"\"",
166            &format!("client_id = \"{}\"", toml_escape(client_id)),
167            1,
168        )
169        .replacen(
170            "# client_secret = \"\"",
171            &format!("client_secret = \"{}\"", toml_escape(client_secret)),
172            1,
173        )
174}
175
176fn instructions() -> String {
177    let mut out = String::new();
178    out.push_str("grr needs one Google OAuth client. Three steps, about five minutes:\n\n");
179    out.push_str(&format!(
180        "1. Create a project\n   {CONSOLE}/projectcreate\n\n"
181    ));
182    out.push_str(&format!(
183        "2. Enable the APIs (one command if you have gcloud):\n     gcloud services enable {}\n\n",
184        SERVICE_APIS
185            .iter()
186            .map(|(_, host, _)| *host)
187            .collect::<Vec<_>>()
188            .join(" ")
189    ));
190    out.push_str(&format!(
191        "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"
192    ));
193    out.push_str("Full walkthrough: ");
194    out.push_str(SETUP_DOCS);
195    out.push('\n');
196    out
197}
198
199pub async fn handle_setup(args: SetupArgs) -> Result<()> {
200    let path = config_path()?;
201    let recipe = instructions();
202
203    if args.print_only {
204        print_output(
205            &serde_json::json!({
206                "instructions": recipe,
207                "config_path": path.display().to_string(),
208                "wrote_config": false,
209                "apis": SERVICE_APIS.iter().map(|(_, host, label)| serde_json::json!({"service": host, "label": label})).collect::<Vec<_>>(),
210            }),
211            args.format,
212        )?;
213        return Ok(());
214    }
215
216    // Non-interactive callers get a hard error instead of a silent hang.
217    let interactive = std::io::stdin().is_terminal();
218
219    let client_id = match args.client_id {
220        Some(id) => id,
221        None => {
222            if !interactive {
223                bail!(
224                    "--client-id is required when stdin is not a terminal (CI, agents, pipes).\n\n{recipe}"
225                );
226            }
227            prompt("Client ID (…apps.googleusercontent.com): ").await?
228        }
229    };
230    validate_client_id(&client_id)?;
231
232    let client_secret = match args.client_secret {
233        Some(secret) => secret,
234        None => {
235            if !interactive {
236                bail!(
237                    "--client-secret is required when stdin is not a terminal (CI, agents, pipes).\n\n{recipe}"
238                );
239            }
240            prompt("Client secret: ").await?
241        }
242    };
243    validate_client_secret(&client_secret)?;
244
245    if path.exists() && !args.force {
246        bail!(
247            "{} already exists; it will not be overwritten.\n\
248             Rerun with --force to replace it.",
249            path.display()
250        );
251    }
252
253    if let Some(parent) = path.parent() {
254        std::fs::create_dir_all(parent)
255            .with_context(|| format!("creating {}", parent.display()))?;
256    }
257    let contents = render_config(client_id.trim(), client_secret.trim());
258    std::fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))?;
259
260    #[cfg(unix)]
261    {
262        use std::os::unix::fs::PermissionsExt;
263        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
264    }
265
266    let mut apis_enabled = false;
267    if args.enable_apis {
268        match enable_apis_via_gcloud().await {
269            Ok(()) => apis_enabled = true,
270            Err(e) => eprintln!("warning: could not enable APIs automatically: {e}"),
271        }
272    }
273
274    print_output(
275        &serde_json::json!({
276            "wrote_config": true,
277            "config_path": path.display().to_string(),
278            "client_id": client_id.trim(),
279            "apis_enabled": apis_enabled,
280            "next_step": "grr auth login",
281        }),
282        args.format,
283    )?;
284
285    Ok(())
286}
287
288/// Read one line from the terminal without pinning a runtime worker.
289///
290/// `handle_setup` is awaited from the async CLI path, and the read can block
291/// until the user hits enter. The caller has already refused non-TTY stdin
292/// (setup.rs's interactive guard), so this await cannot hang a pipe.
293async fn prompt(label: &str) -> Result<String> {
294    use std::io::Write;
295    eprint!("{label}");
296    std::io::stderr().flush().ok();
297    let mut line = String::new();
298    let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
299    tokio::io::AsyncBufReadExt::read_line(&mut stdin, &mut line).await?;
300    Ok(line.trim().to_owned())
301}
302
303/// Best-effort API enablement. The gcloud CLI is optional; everything else
304/// in setup works without it.
305///
306/// `gcloud services enable` is a network-backed call that can take minutes;
307/// spawning it through tokio keeps the runtime free while it runs.
308async fn enable_apis_via_gcloud() -> Result<()> {
309    let services = SERVICE_APIS
310        .iter()
311        .map(|(_, host, _)| *host)
312        .collect::<Vec<_>>()
313        .join(" ");
314
315    let status = tokio::process::Command::new("gcloud")
316        .args(["services", "enable", &services])
317        .status()
318        .await
319        .context("gcloud is not on PATH (see https://cloud.google.com/sdk/docs/install)")?;
320
321    if !status.success() {
322        bail!("`gcloud services enable` exited with {status}");
323    }
324    Ok(())
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn accepts_a_real_looking_client_id() {
333        assert!(validate_client_id("123-abc.apps.googleusercontent.com").is_ok());
334        assert!(validate_client_id("  123-abc.apps.googleusercontent.com  ").is_ok());
335    }
336
337    #[test]
338    fn rejects_a_wrong_or_truncated_client_id() {
339        // The classic copy-paste failures.
340        assert!(validate_client_id("123-abc").is_err());
341        assert!(validate_client_id("123-abc.googleusercontent.com").is_err());
342        assert!(validate_client_id("").is_err());
343        assert!(validate_client_id("   ").is_err());
344    }
345
346    #[test]
347    fn rejects_a_truncated_client_secret() {
348        assert!(validate_client_secret("GOCSPX-abc123def456").is_ok());
349        assert!(validate_client_secret("short").is_err());
350        assert!(validate_client_secret("   ").is_err());
351    }
352
353    #[test]
354    fn escapes_quotes_when_writing_toml() {
355        assert_eq!(toml_escape(r#"a"b\c"#), r#"a\"b\\c"#);
356    }
357
358    #[test]
359    fn rendered_config_mirrors_the_example_with_the_client_filled_in() {
360        let rendered = render_config("123-abc.apps.googleusercontent.com", "GOCSPX-secret");
361        // The full current shape, comments and all.
362        assert!(rendered.contains("[oauth]"));
363        assert!(rendered.contains("client_id = \"123-abc.apps.googleusercontent.com\""));
364        assert!(rendered.contains("client_secret = \"GOCSPX-secret\""));
365        assert!(
366            rendered.contains("[systemone]"),
367            "the commented systemone block must ride along"
368        );
369        assert!(rendered.contains("GRR_CONFIG_PATH"));
370        // The empty placeholder was replaced, not duplicated.
371        assert!(!rendered.contains("client_id = \"\""));
372        assert!(!rendered.contains("# client_secret = \"\""));
373    }
374
375    #[test]
376    fn instructions_cover_every_service() {
377        let text = instructions();
378        for (_, host, _) in SERVICE_APIS {
379            assert!(text.contains(*host), "missing {host}");
380        }
381        assert!(text.contains("Desktop app"));
382    }
383
384    /// The list `grr auth setup` enables is the index's own service set: same
385    /// ids, no more and no fewer. A service added to `discovery.rs` (or dropped
386    /// from it) must fail here rather than silently vanish from the recipe.
387    #[test]
388    fn every_indexed_service_is_here() {
389        let indexed: std::collections::BTreeSet<&str> =
390            crate::discovery::services().keys().copied().collect();
391        let listed: std::collections::BTreeSet<&str> =
392            SERVICE_APIS.iter().map(|(id, _, _)| *id).collect();
393        assert_eq!(
394            listed, indexed,
395            "SERVICE_APIS and the embedded index disagree; a service must not be \
396             enabled by hand or left out of `gcloud services enable`"
397        );
398
399        for (id, host, label) in SERVICE_APIS {
400            assert!(!label.is_empty(), "{id} has no human label");
401            // Where the index carries a service-specific rootUrl, it must back
402            // the host typed here — the two `www.googleapis.com` entries
403            // (Calendar, Drive) are exactly the ones the index cannot name.
404            let root = crate::discovery::services()[*id]
405                .root_url
406                .as_deref()
407                .unwrap_or_default()
408                .trim_start_matches("https://")
409                .trim_end_matches('/')
410                .to_owned();
411            if root != "www.googleapis.com" {
412                assert_eq!(*host, root, "{id}'s gcloud name disagrees with the index");
413            }
414        }
415    }
416
417    #[test]
418    fn writes_where_the_loader_reads() {
419        // `grr auth setup` and the loader must agree on the path, or setup
420        // writes a file nothing ever reads.
421        assert_eq!(config_path().unwrap(), ConfigLoader::config_path().unwrap());
422    }
423}