Skip to content

Commit 4e05616

Browse files
authored
feat(auth): tag login signup link with CLI attribution via OSC 8 (DX-5765) (#37)
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.
1 parent 79788dd commit 4e05616

2 files changed

Lines changed: 143 additions & 3 deletions

File tree

src/commands/auth.rs

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,43 @@ use quicknode_sdk::QuicknodeSdk;
1414
use crate::config::{self, KeySource};
1515
use crate::context::{sdk_config, GlobalArgs};
1616
use crate::errors::CliError;
17+
use crate::output::osc8_link;
18+
19+
/// The signup URL shown to the user in the login welcome. Stays clean.
20+
const SIGNUP_URL: &str = "https://www.quicknode.com/signup";
21+
22+
/// The click target for the signup link: the clean URL plus CLI attribution
23+
/// params. Carried via an OSC 8 hyperlink so it never appears in the visible
24+
/// text. Must keep [`SIGNUP_URL`] as its prefix (asserted in tests).
25+
const SIGNUP_URL_TAGGED: &str =
26+
"https://www.quicknode.com/signup?utm_source=cli&utm_medium=cli&utm_campaign=clams";
27+
28+
/// Whether to emit an OSC 8 hyperlink for the signup line. Mirrors the
29+
/// color-suppression rules in [`crate::output::OutputCtx::detect_with`]: a
30+
/// hyperlink is an ANSI escape, so the same opt-outs apply. The welcome only
31+
/// prints on the interactive TTY path, so no TTY check is needed here. Taking
32+
/// the flag + env values as arguments keeps this testable without mutating
33+
/// process env (which races across parallel tests).
34+
fn hyperlinks_enabled(
35+
no_color: bool,
36+
no_color_env: Option<std::ffi::OsString>,
37+
term_env: Option<String>,
38+
) -> bool {
39+
!no_color
40+
&& no_color_env.map_or(true, |v| v.is_empty())
41+
&& term_env.map_or(true, |t| t != "dumb")
42+
}
43+
44+
/// The signup line for the login welcome: an OSC 8 hyperlink (clean visible
45+
/// text, tagged target) when `hyperlinks` is true, otherwise the plain clean
46+
/// URL with no params.
47+
fn signup_link(hyperlinks: bool) -> String {
48+
if hyperlinks {
49+
osc8_link(SIGNUP_URL_TAGGED, SIGNUP_URL)
50+
} else {
51+
SIGNUP_URL.to_string()
52+
}
53+
}
1754

1855
#[derive(Debug, ClapArgs)]
1956
#[command(after_help = "Examples:\n \
@@ -71,13 +108,19 @@ async fn login(args: LoginArgs, global: GlobalArgs) -> Result<(), CliError> {
71108
));
72109
}
73110
if !global.quiet {
111+
let signup = signup_link(hyperlinks_enabled(
112+
global.no_color,
113+
std::env::var_os("NO_COLOR"),
114+
std::env::var("TERM").ok(),
115+
));
74116
let _ = writeln!(
75117
std::io::stderr(),
76118
"Welcome! The qn CLI uses a Quicknode API key to manage your account.\n\
77119
Your key is stored locally in {}.\n\n \
78120
Get an API key: https://dashboard.quicknode.com/api-keys\n \
79-
Need an account? https://www.quicknode.com/signup\n",
80-
path.display()
121+
Need an account? {}\n",
122+
path.display(),
123+
signup
81124
);
82125
}
83126
config::prompt_for_api_key()?
@@ -182,7 +225,7 @@ fn print_status(global: &GlobalArgs, source: KeySource, redacted: &str, validate
182225

183226
#[cfg(test)]
184227
mod tests {
185-
use super::redact;
228+
use super::{hyperlinks_enabled, redact, signup_link, SIGNUP_URL, SIGNUP_URL_TAGGED};
186229

187230
#[test]
188231
fn redact_short_keys_returns_just_stars() {
@@ -203,4 +246,64 @@ mod tests {
203246
assert_eq!(out.chars().count(), 8); // "****" + last 4 chars
204247
assert!(out.ends_with("δεζη"));
205248
}
249+
250+
#[test]
251+
fn tagged_signup_url_extends_clean_url_with_utm_params() {
252+
// The visible label and the click target must not drift apart: the
253+
// tagged target is the clean URL plus the CLI attribution params.
254+
assert!(
255+
SIGNUP_URL_TAGGED.starts_with(SIGNUP_URL),
256+
"tagged URL must keep the clean URL as its prefix"
257+
);
258+
assert!(SIGNUP_URL_TAGGED.contains("utm_source=cli"));
259+
assert!(SIGNUP_URL_TAGGED.contains("utm_medium=cli"));
260+
assert!(SIGNUP_URL_TAGGED.contains("utm_campaign=clams"));
261+
// The clean URL carries no params — nothing is shown to the user.
262+
assert!(!SIGNUP_URL.contains('?'));
263+
}
264+
265+
#[test]
266+
fn signup_link_hyperlink_hides_params_in_visible_text() {
267+
let link = signup_link(true);
268+
// Visible label is the clean URL; the params live in the escape target.
269+
assert!(link.contains(SIGNUP_URL_TAGGED), "target missing");
270+
assert!(link.contains('\x1b'), "expected OSC 8 escape");
271+
}
272+
273+
#[test]
274+
fn signup_link_plain_is_clean_url_without_params() {
275+
let link = signup_link(false);
276+
assert_eq!(link, SIGNUP_URL);
277+
assert!(!link.contains('\x1b'));
278+
assert!(!link.contains("utm_"));
279+
}
280+
281+
#[test]
282+
fn hyperlinks_disabled_by_no_color_flag() {
283+
assert!(!hyperlinks_enabled(true, None, None));
284+
}
285+
286+
#[test]
287+
fn hyperlinks_disabled_by_no_color_env() {
288+
assert!(!hyperlinks_enabled(false, Some("1".into()), None));
289+
}
290+
291+
#[test]
292+
fn empty_no_color_env_does_not_disable_hyperlinks() {
293+
assert!(hyperlinks_enabled(false, Some("".into()), None));
294+
}
295+
296+
#[test]
297+
fn hyperlinks_disabled_by_term_dumb() {
298+
assert!(!hyperlinks_enabled(false, None, Some("dumb".into())));
299+
}
300+
301+
#[test]
302+
fn hyperlinks_enabled_with_no_overrides() {
303+
assert!(hyperlinks_enabled(
304+
false,
305+
None,
306+
Some("xterm-256color".into())
307+
));
308+
}
206309
}

src/output.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,19 @@ pub fn write_pagination_footer(
333333
}
334334
}
335335

336+
/// Wraps `text` in an OSC 8 terminal-hyperlink escape so a clean visible
337+
/// label can point at a different target URL. Terminals that support OSC 8
338+
/// (iTerm2, kitty, wezterm, recent gnome-terminal, …) render `text` as a
339+
/// clickable link to `url`; terminals that don't simply show `text`.
340+
///
341+
/// The two arguments are intentionally separate: callers can display one URL
342+
/// and link to another (e.g. a clean URL with the click target carrying query
343+
/// params). Suppression is the caller's job — when hyperlinks aren't wanted,
344+
/// print the plain text instead of calling this.
345+
pub(crate) fn osc8_link(url: &str, text: &str) -> String {
346+
format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
347+
}
348+
336349
#[cfg(test)]
337350
mod tests {
338351
use super::*;
@@ -517,6 +530,30 @@ mod tests {
517530
assert!(!Format::Md.is_structured());
518531
}
519532

533+
#[test]
534+
fn osc8_link_frames_text_with_escape_and_target() {
535+
let s = osc8_link("https://example.com/x", "click here");
536+
assert_eq!(
537+
s,
538+
"\x1b]8;;https://example.com/x\x1b\\click here\x1b]8;;\x1b\\"
539+
);
540+
}
541+
542+
#[test]
543+
fn osc8_link_display_text_can_differ_from_target() {
544+
// The visible label stays clean while the click target carries params.
545+
let clean = "https://www.quicknode.com/signup";
546+
let tagged = "https://www.quicknode.com/signup?utm_source=cli";
547+
let s = osc8_link(tagged, clean);
548+
// The target appears in the escape; the visible label is the clean URL.
549+
assert!(s.contains(tagged), "target missing: {s:?}");
550+
assert!(s.contains(clean), "label missing: {s:?}");
551+
// The clean label is what sits between the two escape sequences.
552+
let label_start = s.find("\x1b\\").unwrap() + 2;
553+
let label_end = s[label_start..].find('\x1b').unwrap() + label_start;
554+
assert_eq!(&s[label_start..label_end], clean);
555+
}
556+
520557
#[test]
521558
fn flatten_joins_primitive_array_inside_array_element() {
522559
let mut v = serde_json::json!({"data": [{"id": 1, "tags": ["a", "b", "c"]}]});

0 commit comments

Comments
 (0)