@@ -14,6 +14,43 @@ use quicknode_sdk::QuicknodeSdk;
1414use crate :: config:: { self , KeySource } ;
1515use crate :: context:: { sdk_config, GlobalArgs } ;
1616use 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) ]
184227mod 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}
0 commit comments