use crate::core::config_loader::ConfigLoader;
use crate::output::{OutputFormat, print_output};
use anyhow::{Context, Result, bail};
use clap::Args;
use std::io::IsTerminal;
const CONSOLE: &str = "https://console.cloud.google.com";
const SETUP_DOCS: &str = "https://github.com/debanjanbasu/grr-cli/blob/main/docs/gcp-setup.md";
const SERVICE_APIS: &[(&str, &str, &str)] = &[
("gmail", "gmail.googleapis.com", "Gmail"),
("calendar", "calendar-json.googleapis.com", "Calendar"),
("drive", "drive.googleapis.com", "Drive"),
("people", "people.googleapis.com", "Contacts"),
("chat", "chat.googleapis.com", "Chat"),
("forms", "forms.googleapis.com", "Forms"),
("tasks", "tasks.googleapis.com", "Tasks"),
("docs", "docs.googleapis.com", "Docs"),
("sheets", "sheets.googleapis.com", "Sheets"),
("slides", "slides.googleapis.com", "Slides"),
("script", "script.googleapis.com", "Apps Script"),
(
"analyticsadmin",
"analyticsadmin.googleapis.com",
"Analytics Admin",
),
(
"analyticsdata",
"analyticsdata.googleapis.com",
"Analytics Data",
),
(
"searchconsole",
"searchconsole.googleapis.com",
"Search Console",
),
];
#[derive(Args, Debug, Clone)]
pub struct SetupArgs {
#[arg(long)]
pub client_id: Option<String>,
#[arg(long)]
pub client_secret: Option<String>,
#[arg(long)]
pub print_only: bool,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub enable_apis: bool,
#[arg(short, long, value_enum, default_value = "json")]
pub format: OutputFormat,
}
fn validate_client_id(id: &str) -> Result<()> {
let id = id.trim();
if id.is_empty() {
bail!("client id is empty");
}
if !id.ends_with(".apps.googleusercontent.com") {
bail!(
"that does not look like a Google OAuth client id.\n\
Expected something ending in .apps.googleusercontent.com, got:\n {id}\n\
Copy it from Google Cloud console -> Google Auth Platform -> Clients."
);
}
if !id.contains('.') {
bail!("client id looks truncated: {id}");
}
Ok(())
}
fn validate_client_secret(secret: &str) -> Result<()> {
if secret.trim().is_empty() {
bail!("client secret is empty");
}
if secret.trim().len() < 10 {
bail!(
"that client secret looks truncated ({} chars).\n\
Copy the whole value from the OAuth client page.",
secret.trim().len()
);
}
Ok(())
}
fn toml_escape(value: &str) -> String {
value.replace('\\', r"\\").replace('"', "\\\"")
}
const CONFIG_TEMPLATE: &str = include_str!("../../config.toml.example");
fn render_config(client_id: &str, client_secret: &str) -> String {
CONFIG_TEMPLATE
.replacen(
"client_id = \"\"",
&format!("client_id = \"{}\"", toml_escape(client_id)),
1,
)
.replacen(
"# client_secret = \"\"",
&format!("client_secret = \"{}\"", toml_escape(client_secret)),
1,
)
}
fn instructions() -> String {
let mut out = String::new();
out.push_str("grr needs one Google OAuth client. Three steps, about five minutes:\n\n");
out.push_str(&format!(
"1. Create a project\n {CONSOLE}/projectcreate\n\n"
));
out.push_str(&format!(
"2. Enable the APIs (one command if you have gcloud):\n gcloud services enable {}\n\n",
SERVICE_APIS
.iter()
.map(|(_, host, _)| *host)
.collect::<Vec<_>>()
.join(" ")
));
out.push_str(&format!(
"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"
));
out.push_str("Full walkthrough: ");
out.push_str(SETUP_DOCS);
out.push('\n');
out
}
pub async fn handle_setup(args: SetupArgs) -> Result<()> {
let path = ConfigLoader::config_path()?;
let recipe = instructions();
if args.print_only {
print_output(
&serde_json::json!({
"instructions": recipe,
"config_path": path.display().to_string(),
"wrote_config": false,
"apis": SERVICE_APIS.iter().map(|(_, host, label)| serde_json::json!({"service": host, "label": label})).collect::<Vec<_>>(),
}),
args.format,
)?;
return Ok(());
}
let interactive = std::io::stdin().is_terminal();
let client_id = match args.client_id {
Some(id) => id,
None => {
if !interactive {
bail!(
"--client-id is required when stdin is not a terminal (CI, agents, pipes).\n\n{recipe}"
);
}
prompt("Client ID (…apps.googleusercontent.com): ").await?
}
};
validate_client_id(&client_id)?;
let client_secret = match args.client_secret {
Some(secret) => secret,
None => {
if !interactive {
bail!(
"--client-secret is required when stdin is not a terminal (CI, agents, pipes).\n\n{recipe}"
);
}
prompt("Client secret: ").await?
}
};
validate_client_secret(&client_secret)?;
if path.exists() && !args.force {
bail!(
"{} already exists; it will not be overwritten.\n\
Rerun with --force to replace it.",
path.display()
);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let contents = render_config(client_id.trim(), client_secret.trim());
std::fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
let mut apis_enabled = false;
if args.enable_apis {
match enable_apis_via_gcloud().await {
Ok(()) => apis_enabled = true,
Err(e) => eprintln!("warning: could not enable APIs automatically: {e}"),
}
}
print_output(
&serde_json::json!({
"wrote_config": true,
"config_path": path.display().to_string(),
"client_id": client_id.trim(),
"apis_enabled": apis_enabled,
"next_step": "grr auth login",
}),
args.format,
)?;
Ok(())
}
async fn prompt(label: &str) -> Result<String> {
use std::io::Write;
eprint!("{label}");
std::io::stderr().flush().ok();
let mut line = String::new();
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
tokio::io::AsyncBufReadExt::read_line(&mut stdin, &mut line).await?;
Ok(line.trim().to_owned())
}
async fn enable_apis_via_gcloud() -> Result<()> {
let services = SERVICE_APIS
.iter()
.map(|(_, host, _)| *host)
.collect::<Vec<_>>()
.join(" ");
let status = tokio::process::Command::new("gcloud")
.args(["services", "enable", &services])
.status()
.await
.context("gcloud is not on PATH (see https://cloud.google.com/sdk/docs/install)")?;
if !status.success() {
bail!("`gcloud services enable` exited with {status}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_a_real_looking_client_id() {
assert!(validate_client_id("123-abc.apps.googleusercontent.com").is_ok());
assert!(validate_client_id(" 123-abc.apps.googleusercontent.com ").is_ok());
}
#[test]
fn rejects_a_wrong_or_truncated_client_id() {
assert!(validate_client_id("123-abc").is_err());
assert!(validate_client_id("123-abc.googleusercontent.com").is_err());
assert!(validate_client_id("").is_err());
assert!(validate_client_id(" ").is_err());
}
#[test]
fn rejects_a_truncated_client_secret() {
assert!(validate_client_secret("GOCSPX-abc123def456").is_ok());
assert!(validate_client_secret("short").is_err());
assert!(validate_client_secret(" ").is_err());
}
#[test]
fn escapes_quotes_when_writing_toml() {
assert_eq!(toml_escape(r#"a"b\c"#), r#"a\"b\\c"#);
}
#[test]
fn rendered_config_mirrors_the_example_with_the_client_filled_in() {
let rendered = render_config("123-abc.apps.googleusercontent.com", "GOCSPX-secret");
assert!(rendered.contains("[oauth]"));
assert!(rendered.contains("client_id = \"123-abc.apps.googleusercontent.com\""));
assert!(rendered.contains("client_secret = \"GOCSPX-secret\""));
assert!(
rendered.contains("[systemone]"),
"the commented systemone block must ride along"
);
assert!(rendered.contains("GRR_CONFIG_PATH"));
assert!(!rendered.contains("client_id = \"\""));
assert!(!rendered.contains("# client_secret = \"\""));
}
#[test]
fn instructions_cover_every_service() {
let text = instructions();
for (_, host, _) in SERVICE_APIS {
assert!(text.contains(*host), "missing {host}");
}
assert!(text.contains("Desktop app"));
}
#[test]
fn every_indexed_service_is_here() {
let indexed: std::collections::BTreeSet<&str> =
crate::discovery::services().keys().copied().collect();
let listed: std::collections::BTreeSet<&str> =
SERVICE_APIS.iter().map(|(id, _, _)| *id).collect();
assert_eq!(
listed, indexed,
"SERVICE_APIS and the embedded index disagree; a service must not be \
enabled by hand or left out of `gcloud services enable`"
);
for (id, host, label) in SERVICE_APIS {
assert!(!label.is_empty(), "{id} has no human label");
let root = crate::discovery::services()[*id]
.root_url
.as_deref()
.unwrap_or_default()
.trim_start_matches("https://")
.trim_end_matches('/')
.to_owned();
if root != "www.googleapis.com" {
assert_eq!(*host, root, "{id}'s gcloud name disagrees with the index");
}
}
}
}