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/// Every API grr can talk to, so one `gcloud services enable` covers them.
25/// Every one is reachable through the generated command tree; a method's
26/// scopes decide what actually authorises.
27const 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    /// Google OAuth client id (…apps.googleusercontent.com). Prompted for
47    /// when omitted.
48    #[arg(long)]
49    pub client_id: Option<String>,
50
51    /// Google OAuth client secret. Prompted for when omitted.
52    #[arg(long)]
53    pub client_secret: Option<String>,
54
55    /// Print the instructions and the resolved path, but write nothing.
56    /// Useful for a first look, and for agents that only want the recipe.
57    #[arg(long)]
58    pub print_only: bool,
59
60    /// Overwrite an existing ~/.grr/config.toml instead of refusing.
61    #[arg(long)]
62    pub force: bool,
63
64    /// Try `gcloud services enable` for the APIs when gcloud is on PATH.
65    #[arg(long)]
66    pub enable_apis: bool,
67
68    /// Output format
69    #[arg(short, long, value_enum, default_value = "json")]
70    pub format: OutputFormat,
71}
72
73/// Shape check: Google's client ids always end in this suffix, and a
74/// truncated paste is the single most common setup mistake.
75fn 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    // Google desktop-app secrets are the short `GOCSPX-…` form.
95    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
112/// Escape a value for a TOML basic string.
113fn toml_escape(value: &str) -> String {
114    value.replace('\\', r"\\").replace('"', "\\\"")
115}
116
117/// The config template, taken verbatim from the shipped example so the two
118/// can never drift: `config.toml.example` is the single source of the full
119/// shape (the `[oauth]` block, the commented `[systemone]` block, the
120/// resolution-order note), and setup only fills in the client values. The
121/// example is tracked and committed, so it is inside the crate package and
122/// this `include_str!` always resolves.
123const CONFIG_TEMPLATE: &str = include_str!("../../config.toml.example");
124
125/// Fill the client id/secret into the example template.
126///
127/// This is deliberately not a hand-built TOML document: mirroring the
128/// example keeps every explanatory comment and the `[systemone]` block in
129/// the file the user actually reads, and a future example edit propagates.
130///
131/// An existing config is never rewritten in place — `handle_setup` refuses
132/// without `--force`, and nothing else touches the file. That is on purpose:
133/// silent rewrites are the surprise-write class of bug this codebase avoids,
134/// and a config missing `[systemone]` changes nothing because that section's
135/// defaults apply (see `SystemOneConfig`).
136fn 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    // Non-interactive callers get a hard error instead of a silent hang.
191    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
262/// Read one line from the terminal without pinning a runtime worker.
263///
264/// `handle_setup` is awaited from the async CLI path, and the read can block
265/// until the user hits enter. The caller has already refused non-TTY stdin
266/// (setup.rs's interactive guard), so this await cannot hang a pipe.
267async 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
277/// Best-effort API enablement. The gcloud CLI is optional; everything else
278/// in setup works without it.
279///
280/// `gcloud services enable` is a network-backed call that can take minutes;
281/// spawning it through tokio keeps the runtime free while it runs.
282async 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        // The classic copy-paste failures.
314        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        // The full current shape, comments and all.
336        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        // The empty placeholder was replaced, not duplicated.
345        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        // `grr auth setup` and the loader must agree on the path, or setup
361        // writes a file nothing ever reads.
362        assert_eq!(config_path().unwrap(), ConfigLoader::config_path().unwrap());
363    }
364}