Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ arrow-ipc = { version = "=58.1.0", features = ["lz4"] }
clap = { version = "4", features = ["derive", "env"] }
dirs = "6"
duckdb = { version = "=1.10501.0", features = ["bundled", "appender-arrow"] }
open = "5"
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ureq = { version = "3", features = ["json"] }
Expand Down
4 changes: 2 additions & 2 deletions skills/apitally-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Run commands with `npx` (no install needed):
npx @apitally/cli <command> [--api-key <key>]
```

A team-scoped API key is required to use the CLI. The `auth` command writes the provided API key to `~/.apitally/auth.json`. It's then used by all subsequent commands unless overridden by the `--api-key` flag.
A team-scoped API key is required to use the CLI. The `auth` command saves an API key to `~/.apitally/auth.json`, which is then used by all subsequent commands unless overridden by the `--api-key` flag.

All commands output NDJSON to stdout by default. With `--db`, data is written to a DuckDB database instead (`~/.apitally/data.duckdb` by default), enabling SQL queries via the `sql` command.

Expand Down Expand Up @@ -50,7 +50,7 @@ All commands are run via `npx @apitally/cli <command>`. For full details, see [r

## Investigation Workflow

1. **Check authentication** — run `npx @apitally/cli whoami`. If it fails, ask the user to run `npx @apitally/cli auth` to set their API key. Explain that API keys can be created in the Apitally dashboard under Settings > API keys (https://app.apitally.io/settings/api-keys).
1. **Check authentication** — run `npx @apitally/cli whoami`. If it fails, ask the user to run `npx @apitally/cli auth` to authenticate.

2. **Identify the app** — run `npx @apitally/cli apps` to list apps and get their IDs. If there is more than one app, and the correct app can't be inferred from the user's messages, ask the user which app they mean. Use the app ID consistently for all commands and SQL `WHERE` conditions throughout the investigation.

Expand Down
4 changes: 3 additions & 1 deletion skills/apitally-cli/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ Commands that accept a `--db` flag use `~/.apitally/data.duckdb` as the default
npx @apitally/cli auth [--api-key <key>]
```

Configure API key interactively or by providing a key directly. Saves API key to `~/.apitally/auth.json`.
Opens a browser-based auth flow where the user logs in to the Apitally dashboard and selects a team. A newly created API key is then passed back to the CLI. The key is saved to `~/.apitally/auth.json` and used by all subsequent commands unless overridden by the `--api-key` flag.

If `--api-key` is provided, the key is saved directly without opening the browser.

## `whoami`

Expand Down
222 changes: 200 additions & 22 deletions src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
use std::fs;
use std::io::{self, BufRead, Write};
use std::io::{self, BufRead, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::utils::auth_err;
use regex::Regex;

use crate::utils::{ansi, auth_err};

const DEFAULT_API_BASE_URL: &str = "https://api.apitally.io";

Expand Down Expand Up @@ -74,6 +80,14 @@ fn pick_api_base_url(api_base_url: Option<&str>, config: Option<&AuthConfig>) ->
DEFAULT_API_BASE_URL.to_string()
}

fn validate_api_key(api_key: &str) -> Result<()> {
let re = Regex::new(r"^[a-zA-Z0-9]{7}\.[a-zA-Z0-9]{32}$").unwrap();
if !re.is_match(api_key) {
return Err(auth_err("invalid API key format"));
}
Ok(())
}

/// Resolve API key with precedence: --api-key flag / APITALLY_API_KEY env (via clap) > auth.json
pub fn resolve_api_key(api_key: Option<&str>) -> Result<String> {
let config = load_auth_file(&auth_file_path()?)?;
Expand All @@ -91,42 +105,139 @@ pub fn resolve_api_base_url(api_base_url: Option<&str>) -> String {
pub fn run(
api_key: Option<String>,
api_base_url: Option<String>,
app_url: &str,
auth_file_path: &Path,
input: &mut impl io::Read,
input: Option<Box<dyn Read + Send>>,
) -> Result<()> {
let api_key = match api_key {
Some(key) => key,
None => prompt_api_key(input)?,
None => browser_auth(app_url, input)?,
};
validate_api_key(&api_key)?;
save_auth_file(
auth_file_path,
&AuthConfig {
api_key,
api_base_url,
},
)?;
eprintln!("Authentication configured successfully.");
eprintln!("{}", ansi("1;32", "API key configured successfully."));
Ok(())
}

fn prompt_api_key(input: &mut impl io::Read) -> Result<String> {
eprintln!("To get your API key, go to https://app.apitally.io/settings/api-keys");
eprintln!();
eprint!("API key: ");
fn browser_auth(app_url: &str, input: Option<Box<dyn Read + Send>>) -> Result<String> {
let listener =
TcpListener::bind("127.0.0.1:0").context("failed to start local callback server")?;
let port = listener.local_addr()?.port();
let url = format!("{app_url}/cli-auth?callback_port={port}");

#[cfg(not(test))]
let _ = open::that(&url);

eprintln!("Opening browser with URL: {url}\n");
eprintln!("Complete the auth flow in the browser.");
if input.is_some() {
eprint!("Or paste your API key and press Enter: ");
}
io::stderr().flush()?;

let (tx, rx) = mpsc::channel();

if let Some(input) = input {
let tx_stdin = tx.clone();
thread::spawn(move || read_stdin(tx_stdin, input));
}

let app_url = app_url.to_string();
thread::spawn(move || run_callback_server(listener, tx, &app_url));

let api_key = rx.recv_timeout(Duration::from_secs(300)).map_err(|_| {
eprintln!("\n");
auth_err("authentication timed out")
})?;
Ok(api_key)
}

fn read_stdin(tx: mpsc::Sender<String>, input: Box<dyn Read + Send>) {
let mut line = String::new();
io::BufReader::new(input).read_line(&mut line)?;
let key = line.trim().to_string();
if key.is_empty() {
return Err(auth_err("API key cannot be empty"));
if io::BufReader::new(input).read_line(&mut line).is_ok() {
let key = line.trim().to_string();
if !key.is_empty() {
eprintln!();
let _ = tx.send(key);
}
}
Ok(key)
}

fn run_callback_server(listener: TcpListener, tx: mpsc::Sender<String>, app_url: &str) {
listener.set_nonblocking(false).ok();
while let Ok((mut stream, _)) = listener.accept() {
if let Some(api_key) = handle_callback_request(&mut stream, app_url) {
eprintln!("\n");
let _ = tx.send(api_key);
return;
}
}
}

fn handle_callback_request(stream: &mut TcpStream, app_url: &str) -> Option<String> {
stream
.set_read_timeout(Some(std::time::Duration::from_secs(1)))
.ok();
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf).ok()?;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
let request = std::str::from_utf8(&buf[..n]).ok()?;

if request.starts_with("OPTIONS ") {
let response = format!(
"HTTP/1.1 204 No Content\r\n\
Access-Control-Allow-Origin: {app_url}\r\n\
Access-Control-Allow-Methods: POST\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Content-Length: 0\r\n\
\r\n"
);
stream.write_all(response.as_bytes()).ok();
return None;
}

if request.starts_with("POST ") {
let api_key = request
.split("\r\n\r\n")
.nth(1)
.and_then(|body| serde_json::from_str::<serde_json::Value>(body).ok())
.and_then(|parsed| parsed["api_key"].as_str().map(String::from));

if let Some(api_key) = api_key {
let response = format!(
"HTTP/1.1 200 OK\r\n\
Access-Control-Allow-Origin: {app_url}\r\n\
Content-Length: 0\r\n\
\r\n"
);
stream.write_all(response.as_bytes()).ok();
return Some(api_key);
}

let response = format!(
"HTTP/1.1 400 Bad Request\r\n\
Access-Control-Allow-Origin: {app_url}\r\n\
Content-Length: 0\r\n\
\r\n"
);
stream.write_all(response.as_bytes()).ok();
return None;
}

None
}

#[cfg(test)]
mod tests {
use super::*;

const TEST_API_KEY: &str = "aBcDeFg.01234567890123456789012345678901";

#[test]
fn test_save_and_load_config() {
let dir = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -178,32 +289,99 @@ mod tests {
}

#[test]
fn test_run_with_provided_key() {
fn test_run_with_api_key_flag() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
run(
Some("provided-key".into()),
Some(TEST_API_KEY.into()),
Some("https://custom.api".into()),
"https://app.apitally.io",
&path,
&mut io::empty(),
None,
)
.unwrap();
let config = load_auth_file(&path).unwrap().unwrap();
assert_eq!(config.api_key, "provided-key");
assert_eq!(config.api_key, TEST_API_KEY);
assert_eq!(config.api_base_url.as_deref(), Some("https://custom.api"));
}

#[test]
fn test_run_with_prompted_key() {
fn test_run_with_stdin() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
let mut input = io::Cursor::new(b"prompted-key\n");
run(None, None, &path, &mut input).unwrap();
let input: Box<dyn Read + Send> =
Box::new(io::Cursor::new(format!("{TEST_API_KEY}\n").into_bytes()));
run(None, None, "https://app.apitally.io", &path, Some(input)).unwrap();
let config = load_auth_file(&path).unwrap().unwrap();
assert_eq!(config.api_key, "prompted-key");
assert_eq!(config.api_key, TEST_API_KEY);
assert!(config.api_base_url.is_none());
}

#[test]
fn test_run_with_callback() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();

let (tx, rx) = mpsc::channel();
let app_url = "https://app.apitally.io";
let app_url_owned = app_url.to_string();
thread::spawn(move || run_callback_server(listener, tx, &app_url_owned));

// Send CORS preflight
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap();
stream
.write_all(b"OPTIONS /callback HTTP/1.1\r\nOrigin: https://app.apitally.io\r\n\r\n")
.unwrap();
let mut response = vec![0u8; 1024];
let n = stream.read(&mut response).unwrap();
let response_str = std::str::from_utf8(&response[..n]).unwrap();
assert!(response_str.contains("204"));
assert!(response_str.contains(&format!("Access-Control-Allow-Origin: {app_url}")));

// Send callback POST with invalid data
let body = r#"{"invalid":"data"}"#;
let request = format!(
"POST /callback HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap();
stream.write_all(request.as_bytes()).unwrap();
let mut response = vec![0u8; 1024];
let n = stream.read(&mut response).unwrap();
let response_str = std::str::from_utf8(&response[..n]).unwrap();
assert!(response_str.contains("400"));
assert!(response_str.contains(&format!("Access-Control-Allow-Origin: {app_url}")));

// Send callback POST with valid api_key
let body = format!(r#"{{"api_key":"{TEST_API_KEY}"}}"#);
let request = format!(
"POST /callback HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap();
stream.write_all(request.as_bytes()).unwrap();
let mut response = vec![0u8; 1024];
let n = stream.read(&mut response).unwrap();
let response_str = std::str::from_utf8(&response[..n]).unwrap();
assert!(response_str.contains("200"));

let api_key = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(api_key, TEST_API_KEY);
}

#[test]
fn test_validate_api_key() {
assert!(validate_api_key(TEST_API_KEY).is_ok());
assert!(validate_api_key("short.01234567890123456789012345678901").is_err());
assert!(validate_api_key("aBcDeFg.short").is_err());
assert!(validate_api_key("invalid-key").is_err());
assert!(validate_api_key("").is_err());
assert!(validate_api_key("abc!eFg.01234567890123456789012345678901").is_err());
assert!(validate_api_key("aBcDeFg.0123456789012345678901234567890!").is_err());
}

#[test]
fn test_pick_api_base_url() {
assert_eq!(
Expand Down
Loading
Loading