From 6035d162de02064d8857c83431ebd3401f592332 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Tue, 16 Jun 2026 10:28:23 -0400 Subject: [PATCH] feat(auth): tag login signup link with CLI attribution via OSC 8 (DX-5765) The `qn auth login` welcome prints a signup link. Carry CLI attribution params (utm_source/utm_medium/utm_campaign) on the click target while keeping the visible URL clean, using an OSC 8 terminal hyperlink: the displayed text stays https://www.quicknode.com/signup and the link target holds the params. Add a pub(crate) `osc8_link(url, text)` helper in output.rs. In auth.rs, define the clean and tagged URLs as constants and pick hyperlink vs. plain text using the same suppression rules as color (--no-color, NO_COLOR, TERM=dumb); when suppressed the line degrades to the clean plain URL with no params. The dashboard link is unchanged. Adds 9 unit tests (osc8 framing, divergent display/target, URL-param guard, suppression decision). 116 lib tests pass. --- src/commands/auth.rs | 109 +++++++++++++++++++++++++++++++++++++++++-- src/output.rs | 37 +++++++++++++++ 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 9cd849a..6306638 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -14,6 +14,43 @@ use quicknode_sdk::QuicknodeSdk; use crate::config::{self, KeySource}; use crate::context::{sdk_config, GlobalArgs}; use crate::errors::CliError; +use crate::output::osc8_link; + +/// The signup URL shown to the user in the login welcome. Stays clean. +const SIGNUP_URL: &str = "https://www.quicknode.com/signup"; + +/// The click target for the signup link: the clean URL plus CLI attribution +/// params. Carried via an OSC 8 hyperlink so it never appears in the visible +/// text. Must keep [`SIGNUP_URL`] as its prefix (asserted in tests). +const SIGNUP_URL_TAGGED: &str = + "https://www.quicknode.com/signup?utm_source=cli&utm_medium=cli&utm_campaign=clams"; + +/// Whether to emit an OSC 8 hyperlink for the signup line. Mirrors the +/// color-suppression rules in [`crate::output::OutputCtx::detect_with`]: a +/// hyperlink is an ANSI escape, so the same opt-outs apply. The welcome only +/// prints on the interactive TTY path, so no TTY check is needed here. Taking +/// the flag + env values as arguments keeps this testable without mutating +/// process env (which races across parallel tests). +fn hyperlinks_enabled( + no_color: bool, + no_color_env: Option, + term_env: Option, +) -> bool { + !no_color + && no_color_env.map_or(true, |v| v.is_empty()) + && term_env.map_or(true, |t| t != "dumb") +} + +/// The signup line for the login welcome: an OSC 8 hyperlink (clean visible +/// text, tagged target) when `hyperlinks` is true, otherwise the plain clean +/// URL with no params. +fn signup_link(hyperlinks: bool) -> String { + if hyperlinks { + osc8_link(SIGNUP_URL_TAGGED, SIGNUP_URL) + } else { + SIGNUP_URL.to_string() + } +} #[derive(Debug, ClapArgs)] #[command(after_help = "Examples:\n \ @@ -71,13 +108,19 @@ async fn login(args: LoginArgs, global: GlobalArgs) -> Result<(), CliError> { )); } if !global.quiet { + let signup = signup_link(hyperlinks_enabled( + global.no_color, + std::env::var_os("NO_COLOR"), + std::env::var("TERM").ok(), + )); let _ = writeln!( std::io::stderr(), "Welcome! The qn CLI uses a Quicknode API key to manage your account.\n\ Your key is stored locally in {}.\n\n \ Get an API key: https://dashboard.quicknode.com/api-keys\n \ - Need an account? https://www.quicknode.com/signup\n", - path.display() + Need an account? {}\n", + path.display(), + signup ); } config::prompt_for_api_key()? @@ -182,7 +225,7 @@ fn print_status(global: &GlobalArgs, source: KeySource, redacted: &str, validate #[cfg(test)] mod tests { - use super::redact; + use super::{hyperlinks_enabled, redact, signup_link, SIGNUP_URL, SIGNUP_URL_TAGGED}; #[test] fn redact_short_keys_returns_just_stars() { @@ -203,4 +246,64 @@ mod tests { assert_eq!(out.chars().count(), 8); // "****" + last 4 chars assert!(out.ends_with("δεζη")); } + + #[test] + fn tagged_signup_url_extends_clean_url_with_utm_params() { + // The visible label and the click target must not drift apart: the + // tagged target is the clean URL plus the CLI attribution params. + assert!( + SIGNUP_URL_TAGGED.starts_with(SIGNUP_URL), + "tagged URL must keep the clean URL as its prefix" + ); + assert!(SIGNUP_URL_TAGGED.contains("utm_source=cli")); + assert!(SIGNUP_URL_TAGGED.contains("utm_medium=cli")); + assert!(SIGNUP_URL_TAGGED.contains("utm_campaign=clams")); + // The clean URL carries no params — nothing is shown to the user. + assert!(!SIGNUP_URL.contains('?')); + } + + #[test] + fn signup_link_hyperlink_hides_params_in_visible_text() { + let link = signup_link(true); + // Visible label is the clean URL; the params live in the escape target. + assert!(link.contains(SIGNUP_URL_TAGGED), "target missing"); + assert!(link.contains('\x1b'), "expected OSC 8 escape"); + } + + #[test] + fn signup_link_plain_is_clean_url_without_params() { + let link = signup_link(false); + assert_eq!(link, SIGNUP_URL); + assert!(!link.contains('\x1b')); + assert!(!link.contains("utm_")); + } + + #[test] + fn hyperlinks_disabled_by_no_color_flag() { + assert!(!hyperlinks_enabled(true, None, None)); + } + + #[test] + fn hyperlinks_disabled_by_no_color_env() { + assert!(!hyperlinks_enabled(false, Some("1".into()), None)); + } + + #[test] + fn empty_no_color_env_does_not_disable_hyperlinks() { + assert!(hyperlinks_enabled(false, Some("".into()), None)); + } + + #[test] + fn hyperlinks_disabled_by_term_dumb() { + assert!(!hyperlinks_enabled(false, None, Some("dumb".into()))); + } + + #[test] + fn hyperlinks_enabled_with_no_overrides() { + assert!(hyperlinks_enabled( + false, + None, + Some("xterm-256color".into()) + )); + } } diff --git a/src/output.rs b/src/output.rs index 86084e5..6715d6f 100644 --- a/src/output.rs +++ b/src/output.rs @@ -333,6 +333,19 @@ pub fn write_pagination_footer( } } +/// Wraps `text` in an OSC 8 terminal-hyperlink escape so a clean visible +/// label can point at a different target URL. Terminals that support OSC 8 +/// (iTerm2, kitty, wezterm, recent gnome-terminal, …) render `text` as a +/// clickable link to `url`; terminals that don't simply show `text`. +/// +/// The two arguments are intentionally separate: callers can display one URL +/// and link to another (e.g. a clean URL with the click target carrying query +/// params). Suppression is the caller's job — when hyperlinks aren't wanted, +/// print the plain text instead of calling this. +pub(crate) fn osc8_link(url: &str, text: &str) -> String { + format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\") +} + #[cfg(test)] mod tests { use super::*; @@ -517,6 +530,30 @@ mod tests { assert!(!Format::Md.is_structured()); } + #[test] + fn osc8_link_frames_text_with_escape_and_target() { + let s = osc8_link("https://example.com/x", "click here"); + assert_eq!( + s, + "\x1b]8;;https://example.com/x\x1b\\click here\x1b]8;;\x1b\\" + ); + } + + #[test] + fn osc8_link_display_text_can_differ_from_target() { + // The visible label stays clean while the click target carries params. + let clean = "https://www.quicknode.com/signup"; + let tagged = "https://www.quicknode.com/signup?utm_source=cli"; + let s = osc8_link(tagged, clean); + // The target appears in the escape; the visible label is the clean URL. + assert!(s.contains(tagged), "target missing: {s:?}"); + assert!(s.contains(clean), "label missing: {s:?}"); + // The clean label is what sits between the two escape sequences. + let label_start = s.find("\x1b\\").unwrap() + 2; + let label_end = s[label_start..].find('\x1b').unwrap() + label_start; + assert_eq!(&s[label_start..label_end], clean); + } + #[test] fn flatten_joins_primitive_array_inside_array_element() { let mut v = serde_json::json!({"data": [{"id": 1, "tags": ["a", "b", "c"]}]});