@@ -31,7 +31,7 @@ pub struct WorkspaceEntry {
3131}
3232
3333#[ derive( Debug , Clone , Default , Serialize ) ]
34- pub struct AppUrl ( Option < String > ) ;
34+ pub struct AppUrl ( pub ( crate ) Option < String > ) ;
3535
3636impl Deref for AppUrl {
3737 type Target = str ;
@@ -86,6 +86,10 @@ impl<'de> Deserialize<'de> for ApiUrl {
8686
8787#[ derive( Debug , Clone , Default , Deserialize , Serialize ) ]
8888pub struct ProfileConfig {
89+ // Transient only: populated from `--api-key` and `HOTDATA_API_KEY`,
90+ // never persisted to or read from YAML. Auth state on disk lives
91+ // entirely in session.json.
92+ #[ serde( skip) ]
8993 pub api_key : Option < String > ,
9094 #[ serde( skip) ]
9195 pub api_url : ApiUrl ,
@@ -111,45 +115,22 @@ fn write_config(config_path: &std::path::Path, content: &str) -> Result<(), Stri
111115 fs:: write ( config_path, content) . map_err ( |e| format ! ( "error writing config file: {e}" ) )
112116}
113117
114- pub fn save_api_key ( profile : & str , api_key : & str ) -> Result < ( ) , String > {
115- let config_path = config_path ( ) ?;
116-
117- let mut config_file: ConfigFile = if config_path. exists ( ) {
118- let content = fs:: read_to_string ( & config_path)
119- . map_err ( |e| format ! ( "error reading config file: {e}" ) ) ?;
120- serde_yaml:: from_str ( & content) . map_err ( |e| format ! ( "error parsing config file: {e}" ) ) ?
121- } else {
122- ConfigFile {
123- profiles : HashMap :: new ( ) ,
124- }
125- } ;
126-
127- config_file
128- . profiles
129- . entry ( profile. to_string ( ) )
130- . or_default ( )
131- . api_key = Some ( api_key. to_string ( ) ) ;
132-
133- let content = serde_yaml:: to_string ( & config_file)
134- . map_err ( |e| format ! ( "error serializing config: {e}" ) ) ?;
135-
136- write_config ( & config_path, & content)
137- }
138-
139- pub fn remove_api_key ( profile : & str ) -> Result < ( ) , String > {
118+ /// Wipe the workspace cache for a profile. Paired with
119+ /// `jwt::clear_session()` in `auth::logout` — together they reset the
120+ /// on-disk state that login populates.
121+ pub fn clear_workspaces ( profile : & str ) -> Result < ( ) , String > {
140122 let config_path = config_path ( ) ?;
141123
142124 if !config_path. exists ( ) {
143125 return Ok ( ( ) ) ;
144126 }
145127
146- let content =
147- fs :: read_to_string ( & config_path ) . map_err ( |e| format ! ( "error reading config file: {e}" ) ) ?;
128+ let content = fs :: read_to_string ( & config_path )
129+ . map_err ( |e| format ! ( "error reading config file: {e}" ) ) ?;
148130 let mut config_file: ConfigFile =
149131 serde_yaml:: from_str ( & content) . map_err ( |e| format ! ( "error parsing config file: {e}" ) ) ?;
150132
151133 if let Some ( entry) = config_file. profiles . get_mut ( profile) {
152- entry. api_key = None ;
153134 entry. workspaces . clear ( ) ;
154135 }
155136
@@ -191,15 +172,11 @@ pub fn save_default_workspace(profile: &str, workspace: WorkspaceEntry) -> Resul
191172 . map_err ( |e| format ! ( "error reading config file: {e}" ) ) ?;
192173 serde_yaml:: from_str ( & content) . map_err ( |e| format ! ( "error parsing config file: {e}" ) ) ?
193174 } else {
194- ConfigFile {
195- profiles : HashMap :: new ( ) ,
196- }
175+ ConfigFile { profiles : HashMap :: new ( ) }
197176 } ;
198177
199178 let entry = config_file. profiles . entry ( profile. to_string ( ) ) . or_default ( ) ;
200- entry
201- . workspaces
202- . retain ( |w| w. public_id != workspace. public_id ) ;
179+ entry. workspaces . retain ( |w| w. public_id != workspace. public_id ) ;
203180 entry. workspaces . insert ( 0 , workspace) ;
204181
205182 let content = serde_yaml:: to_string ( & config_file)
@@ -215,9 +192,7 @@ pub fn save_sandbox(profile: &str, sandbox_id: &str) -> Result<(), String> {
215192 . map_err ( |e| format ! ( "error reading config file: {e}" ) ) ?;
216193 serde_yaml:: from_str ( & content) . map_err ( |e| format ! ( "error parsing config file: {e}" ) ) ?
217194 } else {
218- ConfigFile {
219- profiles : HashMap :: new ( ) ,
220- }
195+ ConfigFile { profiles : HashMap :: new ( ) }
221196 } ;
222197
223198 config_file
@@ -238,8 +213,8 @@ pub fn clear_sandbox(profile: &str) -> Result<(), String> {
238213 return Ok ( ( ) ) ;
239214 }
240215
241- let content =
242- fs :: read_to_string ( & config_path ) . map_err ( |e| format ! ( "error reading config file: {e}" ) ) ?;
216+ let content = fs :: read_to_string ( & config_path )
217+ . map_err ( |e| format ! ( "error reading config file: {e}" ) ) ?;
243218 let mut config_file: ConfigFile =
244219 serde_yaml:: from_str ( & content) . map_err ( |e| format ! ( "error parsing config file: {e}" ) ) ?;
245220
@@ -252,10 +227,7 @@ pub fn clear_sandbox(profile: &str) -> Result<(), String> {
252227 write_config ( & config_path, & content)
253228}
254229
255- pub fn resolve_workspace_id (
256- provided : Option < String > ,
257- profile_config : & ProfileConfig ,
258- ) -> Result < String , String > {
230+ pub fn resolve_workspace_id ( provided : Option < String > , profile_config : & ProfileConfig ) -> Result < String , String > {
259231 if let Some ( id) = provided {
260232 return Ok ( id) ;
261233 }
@@ -278,20 +250,14 @@ pub fn load(profile: &str) -> Result<ProfileConfig, String> {
278250 let config_file = config_path ( ) ?;
279251
280252 let mut profile_config = if config_file. exists ( ) {
281- let content = fs :: read_to_string ( & config_file )
282- . map_err ( |e| format ! ( "error reading config file: {e}" ) ) ?;
253+ let content =
254+ fs :: read_to_string ( & config_file ) . map_err ( |e| format ! ( "error reading config file: {e}" ) ) ?;
283255 let config_file: ConfigFile = serde_yaml:: from_str ( & content) . unwrap_or_else ( |_| {
284256 eprintln ! ( "{}" , "error parsing config file." . red( ) ) ;
285- eprintln ! (
286- "Run 'hotdata auth login' (or 'hotdata auth') to generate a new config file."
287- ) ;
257+ eprintln ! ( "Run 'hotdata auth login' (or 'hotdata auth') to generate a new config file." ) ;
288258 std:: process:: exit ( 1 ) ;
289259 } ) ;
290- config_file
291- . profiles
292- . get ( profile)
293- . cloned ( )
294- . unwrap_or_default ( )
260+ config_file. profiles . get ( profile) . cloned ( ) . unwrap_or_default ( )
295261 } else {
296262 ProfileConfig :: default ( )
297263 } ;
@@ -339,75 +305,45 @@ pub mod test_helpers {
339305
340306#[ cfg( test) ]
341307mod tests {
342- use super :: test_helpers:: with_temp_config_dir;
343308 use super :: * ;
309+ use super :: test_helpers:: with_temp_config_dir;
344310
345- #[ test]
346- fn save_and_load_api_key ( ) {
347- let ( _tmp, _guard) = with_temp_config_dir ( ) ;
348-
349- save_api_key ( "default" , "test-key-123" ) . unwrap ( ) ;
350- let profile = load ( "default" ) . unwrap ( ) ;
351- assert_eq ! ( profile. api_key, Some ( "test-key-123" . to_string( ) ) ) ;
311+ fn ws ( id : & str , name : & str ) -> WorkspaceEntry {
312+ WorkspaceEntry { public_id : id. into ( ) , name : name. into ( ) }
352313 }
353314
354315 #[ test]
355- fn save_api_key_creates_config_dir ( ) {
316+ fn save_workspaces_creates_config_dir ( ) {
356317 let ( _tmp, _guard) = with_temp_config_dir ( ) ;
357318
358- // Config file shouldn't exist yet
359319 let path = config_path ( ) . unwrap ( ) ;
360320 assert ! ( !path. exists( ) ) ;
361321
362- save_api_key ( "default" , "key" ) . unwrap ( ) ;
322+ save_workspaces ( "default" , vec ! [ ws ( "ws-1" , "WS" ) ] ) . unwrap ( ) ;
363323 assert ! ( path. exists( ) ) ;
364324 }
365325
366326 #[ test]
367- fn remove_api_key_clears_key_and_workspaces ( ) {
327+ fn clear_workspaces_empties_the_list ( ) {
368328 let ( _tmp, _guard) = with_temp_config_dir ( ) ;
329+ save_workspaces ( "default" , vec ! [ ws( "ws-1" , "Test WS" ) ] ) . unwrap ( ) ;
369330
370- save_api_key ( "default" , "key-to-remove" ) . unwrap ( ) ;
371- save_workspaces (
372- "default" ,
373- vec ! [ WorkspaceEntry {
374- public_id: "ws-1" . into( ) ,
375- name: "Test WS" . into( ) ,
376- } ] ,
377- )
378- . unwrap ( ) ;
379-
380- remove_api_key ( "default" ) . unwrap ( ) ;
331+ clear_workspaces ( "default" ) . unwrap ( ) ;
381332
382333 let profile = load ( "default" ) . unwrap ( ) ;
383- assert_eq ! ( profile. api_key, None ) ;
384334 assert ! ( profile. workspaces. is_empty( ) ) ;
385335 }
386336
387337 #[ test]
388- fn remove_api_key_noop_when_no_config ( ) {
338+ fn clear_workspaces_noop_when_no_config ( ) {
389339 let ( _tmp, _guard) = with_temp_config_dir ( ) ;
390-
391- // Should not error when config file doesn't exist
392- assert ! ( remove_api_key( "default" ) . is_ok( ) ) ;
340+ assert ! ( clear_workspaces( "default" ) . is_ok( ) ) ;
393341 }
394342
395343 #[ test]
396344 fn save_and_load_workspaces ( ) {
397345 let ( _tmp, _guard) = with_temp_config_dir ( ) ;
398-
399- save_api_key ( "default" , "key" ) . unwrap ( ) ;
400- let workspaces = vec ! [
401- WorkspaceEntry {
402- public_id: "ws-1" . into( ) ,
403- name: "First" . into( ) ,
404- } ,
405- WorkspaceEntry {
406- public_id: "ws-2" . into( ) ,
407- name: "Second" . into( ) ,
408- } ,
409- ] ;
410- save_workspaces ( "default" , workspaces) . unwrap ( ) ;
346+ save_workspaces ( "default" , vec ! [ ws( "ws-1" , "First" ) , ws( "ws-2" , "Second" ) ] ) . unwrap ( ) ;
411347
412348 let profile = load ( "default" ) . unwrap ( ) ;
413349 assert_eq ! ( profile. workspaces. len( ) , 2 ) ;
@@ -418,29 +354,10 @@ mod tests {
418354 #[ test]
419355 fn save_default_workspace_moves_to_front ( ) {
420356 let ( _tmp, _guard) = with_temp_config_dir ( ) ;
421-
422- save_api_key ( "default" , "key" ) . unwrap ( ) ;
423- let workspaces = vec ! [
424- WorkspaceEntry {
425- public_id: "ws-1" . into( ) ,
426- name: "First" . into( ) ,
427- } ,
428- WorkspaceEntry {
429- public_id: "ws-2" . into( ) ,
430- name: "Second" . into( ) ,
431- } ,
432- ] ;
433- save_workspaces ( "default" , workspaces) . unwrap ( ) ;
357+ save_workspaces ( "default" , vec ! [ ws( "ws-1" , "First" ) , ws( "ws-2" , "Second" ) ] ) . unwrap ( ) ;
434358
435359 // Set ws-2 as default — should move to front
436- save_default_workspace (
437- "default" ,
438- WorkspaceEntry {
439- public_id : "ws-2" . into ( ) ,
440- name : "Second" . into ( ) ,
441- } ,
442- )
443- . unwrap ( ) ;
360+ save_default_workspace ( "default" , ws ( "ws-2" , "Second" ) ) . unwrap ( ) ;
444361
445362 let profile = load ( "default" ) . unwrap ( ) ;
446363 assert_eq ! ( profile. workspaces[ 0 ] . public_id, "ws-2" ) ;
@@ -450,8 +367,7 @@ mod tests {
450367 #[ test]
451368 fn load_missing_profile_returns_default ( ) {
452369 let ( _tmp, _guard) = with_temp_config_dir ( ) ;
453-
454- save_api_key ( "default" , "key" ) . unwrap ( ) ;
370+ save_workspaces ( "default" , vec ! [ ws( "ws-1" , "WS" ) ] ) . unwrap ( ) ;
455371
456372 let profile = load ( "nonexistent" ) . unwrap ( ) ;
457373 assert_eq ! ( profile. api_key, None ) ;
@@ -467,25 +383,55 @@ mod tests {
467383 }
468384
469385 #[ test]
470- fn multiple_profiles ( ) {
386+ fn multiple_profiles_keep_independent_workspaces ( ) {
471387 let ( _tmp, _guard) = with_temp_config_dir ( ) ;
472-
473- save_api_key ( "default" , "key-default" ) . unwrap ( ) ;
474- save_api_key ( "staging" , "key-staging" ) . unwrap ( ) ;
388+ save_workspaces ( "default" , vec ! [ ws( "ws-default" , "Default WS" ) ] ) . unwrap ( ) ;
389+ save_workspaces ( "staging" , vec ! [ ws( "ws-staging" , "Staging WS" ) ] ) . unwrap ( ) ;
475390
476391 let default = load ( "default" ) . unwrap ( ) ;
477392 let staging = load ( "staging" ) . unwrap ( ) ;
478- assert_eq ! ( default . api_key, Some ( "key-default" . to_string( ) ) ) ;
479- assert_eq ! ( staging. api_key, Some ( "key-staging" . to_string( ) ) ) ;
393+ assert_eq ! ( default . workspaces[ 0 ] . public_id, "ws-default" ) ;
394+ assert_eq ! ( staging. workspaces[ 0 ] . public_id, "ws-staging" ) ;
395+ }
396+
397+ #[ test]
398+ fn legacy_api_key_in_yaml_is_ignored_on_load ( ) {
399+ // Older configs (pre-jwt-branch) had `api_key: hd_xxx` written
400+ // to disk. After the migration, the api_key field is purely
401+ // transient — `#[serde(skip)]` must drop any value present in
402+ // YAML on load. This pins down the migration behavior so a
403+ // stale entry can't silently reappear in profile.api_key.
404+ let ( _tmp, _guard) = with_temp_config_dir ( ) ;
405+ let path = config_path ( ) . unwrap ( ) ;
406+ fs:: create_dir_all ( path. parent ( ) . unwrap ( ) ) . unwrap ( ) ;
407+ fs:: write (
408+ & path,
409+ "profiles:\n default:\n api_key: legacy-hd-token\n " ,
410+ )
411+ . unwrap ( ) ;
412+
413+ let profile = load ( "default" ) . unwrap ( ) ;
414+ assert_eq ! ( profile. api_key, None ) ;
415+ }
416+
417+ #[ test]
418+ fn save_does_not_persist_transient_api_key ( ) {
419+ // Even if api_key was set in-memory (e.g. via env var), saving
420+ // workspaces must NOT round-trip api_key into YAML.
421+ let ( _tmp, _guard) = with_temp_config_dir ( ) ;
422+ save_workspaces ( "default" , vec ! [ ws( "ws-1" , "WS" ) ] ) . unwrap ( ) ;
423+
424+ let yaml = fs:: read_to_string ( config_path ( ) . unwrap ( ) ) . unwrap ( ) ;
425+ assert ! (
426+ !yaml. contains( "api_key" ) ,
427+ "api_key must not appear in YAML, got:\n {yaml}"
428+ ) ;
480429 }
481430
482431 #[ test]
483432 fn resolve_workspace_id_prefers_provided ( ) {
484433 let profile = ProfileConfig {
485- workspaces : vec ! [ WorkspaceEntry {
486- public_id: "ws-1" . into( ) ,
487- name: "WS" . into( ) ,
488- } ] ,
434+ workspaces : vec ! [ WorkspaceEntry { public_id: "ws-1" . into( ) , name: "WS" . into( ) } ] ,
489435 ..Default :: default ( )
490436 } ;
491437 let result = resolve_workspace_id ( Some ( "explicit-id" . into ( ) ) , & profile) . unwrap ( ) ;
@@ -495,10 +441,7 @@ mod tests {
495441 #[ test]
496442 fn resolve_workspace_id_falls_back_to_first ( ) {
497443 let profile = ProfileConfig {
498- workspaces : vec ! [ WorkspaceEntry {
499- public_id: "ws-1" . into( ) ,
500- name: "WS" . into( ) ,
501- } ] ,
444+ workspaces : vec ! [ WorkspaceEntry { public_id: "ws-1" . into( ) , name: "WS" . into( ) } ] ,
502445 ..Default :: default ( )
503446 } ;
504447 let result = resolve_workspace_id ( None , & profile) . unwrap ( ) ;
0 commit comments