Module: Lich::Common::Authentication::CLIPassword

Defined in:
documented/common/authentication/cli_password.rb

Overview

Headless password management operations for CLI execution Thin wrapper around EntryStore and PasswordCipher Handles: change account password, add account, change master password

Examples:

Lich::Common::Authentication::CLIPassword.('DOUG', 'NewPass123')

Class Method Summary collapse

Class Method Details

.add_account(account, password, frontend = nil) ⇒ Integer

Adds new account to entry.yaml Authenticates with game servers to fetch all characters Mimics GUI "Add Account" behavior

Parameters:

  • account (String) —

    Account username

  • password (String) —

    Account password

  • frontend (String, nil) (defaults to: nil) —

    Frontend (wizard, stormfront, avalon, or nil)

Returns:

  • (Integer) —

    Exit code (0=success, 1=error, 2=auth failed)



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'documented/common/authentication/cli_password.rb', line 116

def self.(, password, frontend = nil)
  # Normalize account name to uppercase (accounts are stored uppercase)
   = .upcase

  data_dir = DATA_DIR
  yaml_file = Lich::Common::Authentication::EntryStore.yaml_file_path(data_dir)

  begin
    # Check if account already exists
    if File.exist?(yaml_file)
      yaml_data = YAML.safe_load_file(yaml_file, permitted_classes: [Symbol])
      if yaml_data['accounts'] && yaml_data['accounts'][]
        puts "error: Account '#{}' already exists"
        puts "Use --change-account-password to update the password."
        Lich.log "error: CLI add account failed - account '#{}' already exists"
        return 1
      end
    end

    Lich.log "info: Adding account '#{}' via CLI"

    # Authenticate with game servers to fetch characters (like GUI does)
    puts "Authenticating with game servers..."
    Lich.log "info: Authenticating account '#{}' with game servers"
    auth_data = Lich::Common::Authentication.authenticate(
      account: ,
      password: password,
      legacy: true
    )

    unless auth_data && auth_data.is_a?(Array) && !auth_data.empty?
      puts "error: Authentication failed or no characters found"
      Lich.log "error: CLI add account failed - game server authentication failed for '#{}'"
      return 2
    end

    Lich.log "info: Authentication successful - found #{auth_data.length} character(s)"

    # Determine frontend
    selected_frontend = if frontend
                          # Frontend provided via --frontend flag
                          Lich.log "info: Using provided frontend: #{frontend}"
                          frontend
                        else
                          # Check predominant frontend in YAML, or prompt
                          predominant = determine_predominant_frontend(yaml_file, )
                          if predominant
                            puts "Using predominant frontend: #{predominant}"
                            Lich.log "info: Using predominant frontend: #{predominant}"
                            predominant
                          else
                            # Prompt user
                            prompt_for_frontend
                          end
                        end

    # Convert authentication data to character list
    character_list = Lich::Common::GUI::AccountManager.convert_auth_data_to_characters(
      auth_data,
      selected_frontend || 'stormfront'
    )

    # Save account + characters using AccountManager
    if Lich::Common::GUI::AccountManager.(data_dir, , password, character_list)
      puts "success: Account '#{}' added with #{character_list.length} character(s)"
      Lich.log "info: Account '#{}' added successfully with #{character_list.length} character(s)"
      if selected_frontend.nil? || selected_frontend.empty?
        puts "note: Frontend not set - use GUI to configure or rerun with --frontend"
        Lich.log "warning: No frontend set for account '#{}'"
      end
      0
    else
      puts "error: Failed to save account"
      Lich.log "error: CLI add account failed - could not save account '#{}'"
      1
    end
  rescue StandardError => e
    # CRITICAL: Only log e.message, NEVER log password values
    puts "error: #{e.message}"
    Lich.log "error: CLI add account failed for '#{}': #{e.message}"
    1
  end
end

.add_character(account, char_name, game_code: 'DR', frontend: nil) ⇒ Integer

Adds a single character to an existing account's entry.yaml record, with no server round-trip. Thin CLI wrapper around AccountManager.add_character (the same method the GUI's "Add Character" tab calls) - duplicate detection and account-not-found messaging live there, not here.

The game code is validated here rather than only at the CLI boundary: a direct call carrying a retired code (GSX) or a runtime-only one would otherwise persist a character record whose game_name is 'Unknown'.

Parameters:

  • account (String) —

    Account username

  • char_name (String) —

    Character name

  • game_code (String) (defaults to: 'DR') —

    Game code (default 'DR' - DragonRealms)

  • frontend (String, nil) (defaults to: nil) —

    Frontend (wizard, stormfront, avalon, or nil)

Returns:

  • (Integer) —

    Exit code (0=success, 1=failure)



354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
# File 'documented/common/authentication/cli_password.rb', line 354

def self.add_character(, char_name, game_code: 'DR', frontend: nil)
  unless Lich::Common::Authentication::LoginHelpers.valid_game_code?(game_code)
    valid_codes = Lich::Common::Authentication::LoginHelpers::VALID_GAME_CODES.join(', ')
    puts "error: Invalid game code: #{game_code}"
    puts "Valid game codes: #{valid_codes}"
    Lich.log "error: CLI add character failed for '#{}': invalid game code '#{game_code}'"
    return 1
  end

   = .upcase

  data_dir = DATA_DIR
  yaml_file = Lich::Common::Authentication::EntryStore.yaml_file_path(data_dir)

  begin
    selected_frontend = frontend || determine_predominant_frontend(yaml_file, ) || 'stormfront'

    character_data = {
      char_name: char_name,
      game_code: game_code,
      game_name: Lich::Common::GUI::GameSelection.get_game_name(game_code),
      frontend: selected_frontend,
      custom_launch: nil,
      custom_launch_dir: nil
    }

    result = Lich::Common::GUI::AccountManager.add_character(data_dir, , character_data)

    if result[:success]
      puts "success: #{result[:message]}"
      Lich.log "info: CLI add character succeeded for '#{}': #{result[:message]}"
      0
    else
      puts "error: #{result[:message]}"
      Lich.log "error: CLI add character failed for '#{}': #{result[:message]}"
      1
    end
  rescue StandardError => e
    puts "error: #{e.message}"
    Lich.log "error: CLI add character failed for '#{}': #{e.message}"
    1
  end
end

.change_account_password(account, new_password) ⇒ Integer

Changes account password in entry.yaml Handles all encryption modes (plaintext, standard, enhanced)

Parameters:

  • account (String) —

    Account username

  • new_password (String) —

    New plaintext password

Returns:

  • (Integer) —

    Exit code (0=success, 1=error, 2=not found)



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'documented/common/authentication/cli_password.rb', line 30

def self.(, new_password)
  # Normalize account name to uppercase (accounts are stored uppercase)
   = .upcase

  # Validate master password availability before attempting change
  unless validate_master_password_available
    return 1
  end

  data_dir = DATA_DIR
  yaml_file = Lich::Common::Authentication::EntryStore.yaml_file_path(data_dir)

  unless File.exist?(yaml_file)
    puts "error: entry.yaml not found at #{yaml_file}"
    return 2
  end

  begin
    yaml_data = YAML.safe_load_file(yaml_file, permitted_classes: [Symbol])
    encryption_mode = (yaml_data['encryption_mode'] || 'plaintext').to_sym

    # Find account
    unless yaml_data['accounts'] && yaml_data['accounts'][]
      puts "error: Account '#{}' not found"
      Lich.log "error: CLI change password failed - account '#{}' not found"
      return 2
    end

    Lich.log "info: Changing password for account '#{}' (mode: #{encryption_mode})"

    # Encrypt password based on mode
    encrypted = case encryption_mode
                when :plaintext
                  new_password
                when :standard
                  Lich::Common::GUI::PasswordCipher.encrypt(
                    new_password,
                    mode: :standard,
                    account_name: 
                  )
                when :enhanced
                  master_password = Lich::Common::GUI::MasterPasswordManager.retrieve_master_password
                  if master_password.nil?
                    puts 'error: Enhanced mode requires master password in keychain'
                    Lich.log 'error: CLI change password failed - master password not in keychain'
                    return 1
                  end
                  Lich::Common::GUI::PasswordCipher.encrypt(
                    new_password,
                    mode: :enhanced,
                    account_name: ,
                    master_password: master_password
                  )
                else
                  puts "error: Unknown encryption mode: #{encryption_mode}"
                  Lich.log "error: CLI change password failed - unknown encryption mode: #{encryption_mode}"
                  return 1
                end

    # Update account password
    yaml_data['accounts'][]['password'] = encrypted

    # Save YAML
    File.open(yaml_file, 'w', 0o600) do |file|
      file.write(YAML.dump(yaml_data))
    end

    puts "success: Password changed for account '#{}'"
    Lich.log "info: Password changed successfully for account '#{}'"
    0
  rescue StandardError => e
    # CRITICAL: Only log e.message, NEVER log password values
    puts "error: #{e.message}"
    Lich.log "error: CLI change password failed for '#{}': #{e.message}"
    1
  end
end

.change_master_password(old_password, new_password = nil) ⇒ Integer

Changes master password and re-encrypts all accounts Only works in Enhanced encryption mode Prompts for new password if not provided

Parameters:

  • old_password (String) —

    Current master password

  • new_password (String, nil) (defaults to: nil) —

    New master password (optional, will prompt if nil)

Returns:

  • (Integer) —

    Exit code (0=success, 1=error, 3=wrong mode)



405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
# File 'documented/common/authentication/cli_password.rb', line 405

def self.change_master_password(old_password, new_password = nil)
  data_dir = DATA_DIR
  yaml_file = Lich::Common::Authentication::EntryStore.yaml_file_path(data_dir)

  unless File.exist?(yaml_file)
    puts "error: entry.yaml not found at #{yaml_file}"
    return 2
  end

  begin
    yaml_data = YAML.safe_load_file(yaml_file, permitted_classes: [Symbol])
    encryption_mode = (yaml_data['encryption_mode'] || 'plaintext').to_sym

    unless encryption_mode == :enhanced
      puts "error: Master password only used in Enhanced encryption mode"
      puts "Current mode: #{encryption_mode}"
      Lich.log "error: CLI change master password failed - wrong encryption mode: #{encryption_mode}"
      return 3
    end

    Lich.log "info: Starting CLI master password change"

    # Validate old password
    validation_test = yaml_data['master_password_validation_test']
    unless Lich::Common::GUI::MasterPasswordManager.validate_master_password(old_password, validation_test)
      puts 'error: Current master password incorrect'
      Lich.log 'error: CLI change master password failed - incorrect current password'
      return 1
    end

    Lich.log "info: Current master password validated successfully"

    # Use provided password or prompt for new password
    if new_password.nil?
      print "Enter new master password: "
      input = $stdin.gets
      if input.nil?
        puts 'error: Unable to read password from STDIN / terminal'
        puts 'Please run this command interactively (not in a pipe or automated script without input)'
        Lich.log 'error: CLI change master password failed - stdin unavailable'
        return 1
      end
      new_password = input.strip

      print "Confirm new master password: "
      input = $stdin.gets
      if input.nil?
        puts 'error: Unable to read password from STDIN / terminal'
        puts 'Please run this command interactively (not in a pipe or automated script without input)'
        Lich.log 'error: CLI change master password failed - stdin unavailable'
        return 1
      end
      confirm_password = input.strip

      unless new_password == confirm_password
        puts "error: Passwords do not match"
        Lich.log "error: CLI change master password failed - password confirmation mismatch"
        return 1
      end
    end

    if new_password.length < 8
      puts "error: Password must be at least 8 characters"
      Lich.log "error: CLI change master password failed - password too short"
      return 1
    end

     = yaml_data['accounts'].length
    Lich.log "info: Re-encrypting #{} account(s) with new master password"

    # Re-encrypt all accounts
    yaml_data['accounts'].each do |_username, |
      # Decrypt with old password
      plaintext = Lich::Common::GUI::PasswordCipher.decrypt(
        ['password'],
        mode: :enhanced,
        master_password: old_password
      )

      # Encrypt with new password
      new_encrypted = Lich::Common::GUI::PasswordCipher.encrypt(
        plaintext,
        mode: :enhanced,
        master_password: new_password
      )

      ['password'] = new_encrypted
    end

    # Update validation test
    new_validation = Lich::Common::GUI::MasterPasswordManager.create_validation_test(new_password)
    yaml_data['master_password_validation_test'] = new_validation

    # Update keychain
    unless Lich::Common::GUI::MasterPasswordManager.store_master_password(new_password)
      puts 'error: Failed to update keychain'
      Lich.log 'error: CLI change master password failed - keychain update failed'
      return 1
    end

    # Save YAML
    File.open(yaml_file, 'w', 0o600) do |file|
      file.write(YAML.dump(yaml_data))
    end

    puts 'success: Master password changed'
    Lich.log 'info: Master password changed successfully via CLI'
    0
  rescue StandardError => e
    # CRITICAL: Only log e.message, NEVER log password values
    puts "error: #{e.message}"
    Lich.log "error: CLI change master password failed: #{e.message}"
    1
  end
end

.determine_predominant_frontend(yaml_file, account) ⇒ String?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Determines the predominant frontend among a single account's existing characters. Scoped to one account so a multi-account entry.yaml doesn't leak another account's frontend preference into this one.

Parameters:

  • yaml_file (String) —

    Path to entry.yaml

  • account (String) —

    Account username (already upcased) to scope the count to

Returns:

  • (String, nil) —

    Predominant frontend or nil



559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
# File 'documented/common/authentication/cli_password.rb', line 559

def self.determine_predominant_frontend(yaml_file, )
  return nil unless File.exist?(yaml_file)

  yaml_data = YAML.safe_load_file(yaml_file, permitted_classes: [Symbol])
   = yaml_data['accounts'] && yaml_data['accounts'][]
  return nil unless  && ['characters']

  frontend_counts = Hash.new(0)
  ['characters'].each do |char|
    fe = char['frontend']
    frontend_counts[fe] += 1 if fe && !fe.empty?
  end

  return nil if frontend_counts.empty?

  frontend_counts.max_by { |_fe, count| count }&.first
end

.get_master_password_from_keychain_or_prompt ⇒ String?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Gets master password from keychain or prompts user if not available Used when entering Enhanced mode - checks keychain first, prompts if missing

Returns:

  • (String, nil) —

    Master password or nil if unavailable/cancelled



789
790
791
792
793
794
795
796
797
# File 'documented/common/authentication/cli_password.rb', line 789

def self.get_master_password_from_keychain_or_prompt
  # Check if password already exists in keychain
  existing = Lich::Common::GUI::MasterPasswordManager.retrieve_master_password
  return existing if existing

  # Not in keychain, prompt user to create one
  puts "Creating new master password for Enhanced encryption mode..."
  prompt_and_confirm_password("Enter new master password")
end

.normalize_char_name(name) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Normalizes a character name to the form add_or_update_account persists, so names compare equal regardless of the casing the server or entry.yaml used.

Parameters:

  • name (String, nil) —

    Character name

Returns:

  • (String) —

    Normalized name



547
548
549
# File 'documented/common/authentication/cli_password.rb', line 547

def self.normalize_char_name(name)
  name.to_s.strip.split.map(&:capitalize).join(' ')
end

.prompt_and_confirm_password(prompt = "Enter password") ⇒ String?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Helper: Prompts user for a password with confirmation Used for creating new master passwords or changing existing ones

Parameters:

  • prompt (String) (defaults to: "Enter password") —

    Prompt to show user (default: "Enter password")

Returns:

  • (String, nil) —

    Password if valid, nil if cancelled or error



745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
# File 'documented/common/authentication/cli_password.rb', line 745

def self.prompt_and_confirm_password(prompt = "Enter password")
  print "#{prompt}: "
  input = $stdin.gets
  if input.nil?
    puts 'error: Unable to read password from STDIN / terminal'
    puts 'Please run this command interactively (not in a pipe or automated script without input)'
    Lich.log 'error: Password prompt failed - stdin unavailable'
    return nil
  end
  password = input.strip

  print "Confirm #{prompt.downcase}: "
  input = $stdin.gets
  if input.nil?
    puts 'error: Unable to read password from STDIN / terminal'
    puts 'Please run this command interactively (not in a pipe or automated script without input)'
    Lich.log 'error: Password confirmation failed - stdin unavailable'
    return nil
  end
  confirm_password = input.strip

  unless password == confirm_password
    puts "error: Passwords do not match"
    Lich.log "error: Password confirmation mismatch"
    return nil
  end

  if password.length < 8
    puts "error: Password must be at least 8 characters"
    Lich.log "error: Password too short (minimum 8 characters)"
    return nil
  end

  password
rescue StandardError => e
  Lich.log "error: Password prompt failed: #{e.message}"
  nil
end

.prompt_for_frontend ⇒ String?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Prompts user for frontend selection

Returns:

  • (String, nil) —

    Selected frontend or nil (user skipped)



581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
# File 'documented/common/authentication/cli_password.rb', line 581

def self.prompt_for_frontend
  puts "\nSelect frontend (or press Enter to skip):"
  puts "  1. wizard"
  puts "  2. stormfront"
  puts "  3. avalon"
  print "Choice (1-3 or Enter): "

  input = $stdin.gets
  if input.nil?
    puts 'error: Unable to read input from STDIN / terminal'
    puts 'Please run this command interactively (not in a pipe or automated script without input)'
    return nil
  end
  choice = input.strip
  return nil if choice.empty?

  case choice
  when '1' then 'wizard'
  when '2' then 'stormfront'
  when '3' then 'avalon'
  else
    puts "Invalid choice, skipping frontend selection"
    nil
  end
end

.prompt_for_master_password ⇒ String?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Prompts for master password (single prompt, no confirmation) Used when validating current password when leaving Enhanced mode

Returns:

  • (String, nil) —

    Master password or nil if unavailable



804
805
806
807
808
809
810
811
812
813
814
815
816
# File 'documented/common/authentication/cli_password.rb', line 804

def self.prompt_for_master_password
  print "Enter master password: "
  input = $stdin.gets
  if input.nil?
    puts 'error: Unable to read password from STDIN / terminal'
    Lich.log 'error: Master password prompt failed - stdin unavailable'
    return nil
  end
  input.strip
rescue StandardError => e
  Lich.log "error: Master password prompt failed: #{e.message}"
  nil
end

.recover_master_password(master_password = nil) ⇒ Integer

Recovers access to keychain by validating and restoring original master password User re-enters original master password which is validated against validation_test Account passwords remain unchanged (already encrypted with original password) Only works in Enhanced encryption mode when validation test exists

Parameters:

  • master_password (String, nil) (defaults to: nil) —

    Original master password (optional, will prompt if nil)

Returns:

  • (Integer) —

    Exit code (0=success, 1=error, 2=not found, 3=wrong mode)



663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
# File 'documented/common/authentication/cli_password.rb', line 663

def self.recover_master_password(master_password = nil)
  data_dir = DATA_DIR
  yaml_file = Lich::Common::Authentication::EntryStore.yaml_file_path(data_dir)

  unless File.exist?(yaml_file)
    puts "error: entry.yaml not found at #{yaml_file}"
    return 2
  end

  begin
    yaml_data = YAML.safe_load_file(yaml_file, permitted_classes: [Symbol])
    encryption_mode = (yaml_data['encryption_mode'] || 'plaintext').to_sym

    unless encryption_mode == :enhanced
      puts "error: Master password recovery only works in Enhanced encryption mode"
      puts "Current mode: #{encryption_mode}"
      Lich.log "error: CLI recover master password failed - wrong encryption mode: #{encryption_mode}"
      return 3
    end

    # Must have validation test to validate password
    validation_test = yaml_data['master_password_validation_test']
    unless validation_test
      puts "error: No validation test found - cannot recover master password"
      Lich.log "error: CLI recover master password failed - no validation test"
      return 1
    end

    Lich.log "info: Starting master password recovery"

    # Get password to validate
    if master_password.nil?
      print "Enter master password: "
      input = $stdin.gets
      if input.nil?
        puts 'error: Unable to read password from STDIN / terminal'
        puts 'Please run this command interactively (not in a pipe or automated script without input)'
        Lich.log 'error: CLI recover master password failed - stdin unavailable'
        return 1
      end
      master_password = input.strip
    end

    if master_password.length < 8
      puts "error: Password must be at least 8 characters"
      Lich.log "error: CLI recover master password failed - password too short"
      return 1
    end

    # Validate password against validation test
    unless Lich::Common::GUI::MasterPasswordManager.validate_master_password(master_password, validation_test)
      puts "error: Password validation failed"
      Lich.log "error: CLI recover master password failed - password validation failed"
      return 1
    end

    Lich.log "info: Password validated successfully"

    # Store validated password in keychain
    unless Lich::Common::GUI::MasterPasswordManager.store_master_password(master_password)
      puts 'error: Failed to store master password in keychain'
      Lich.log 'error: CLI recover master password failed - keychain storage failed'
      return 1
    end

    puts 'success: Master password recovered and restored to keychain'
    Lich.log 'info: Master password recovered successfully via CLI'
    0
  rescue StandardError => e
    # CRITICAL: Only log e.message, NEVER log password values
    puts "error: #{e.message}"
    Lich.log "error: CLI recover master password failed: #{e.message}"
    1
  end
end

.refresh_characters(account, frontend = nil) ⇒ Integer

Re-fetches an account's character list from the game servers and merges it into entry.yaml. Only characters the account does not already have stored are appended, each with the selected frontend; every stored record for a character the account already has - including its frontend, custom launch config, and favorite flag, and including several records for the same character - is left exactly as it was. Re-running a refresh that finds nothing new is therefore a no-op that doesn't rewrite entry.yaml at all. Mirrors add_account's authentication flow, but requires the account to already exist (the opposite of add_account's guard), and authenticates using the account's already-stored password (decrypted here) instead of a CLI-supplied one, so the password never appears in shell history or ps.

Parameters:

  • account (String) —

    Account username

  • frontend (String, nil) (defaults to: nil) —

    Frontend (wizard, stormfront, avalon, or nil)

Returns:

  • (Integer) —

    Exit code (0=success, 1=save error, 2=account not found, 3=auth failed, 4=decrypt failed)



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
# File 'documented/common/authentication/cli_password.rb', line 215

def self.refresh_characters(, frontend = nil)
   = .upcase

  data_dir = DATA_DIR
  yaml_file = Lich::Common::Authentication::EntryStore.yaml_file_path(data_dir)

  begin
    unless File.exist?(yaml_file)
      puts "error: Account '#{}' not found"
      Lich.log "error: CLI refresh characters failed - entry.yaml not found"
      return 2
    end

    yaml_data = YAML.safe_load_file(yaml_file, permitted_classes: [Symbol])
    unless yaml_data['accounts'] && yaml_data['accounts'][]
      puts "error: Account '#{}' not found"
      Lich.log "error: CLI refresh characters failed - account '#{}' not found"
      return 2
    end

    Lich.log "info: Refreshing characters for account '#{}' via CLI"

    encryption_mode = (yaml_data['encryption_mode'] || 'plaintext').to_sym
    begin
      password = Lich::Common::Authentication::EntryStore.decrypt_password(
        yaml_data['accounts'][]['password'],
        mode: encryption_mode,
        account_name: 
      )
    rescue StandardError => e
      # CRITICAL: Only log e.message, NEVER log password values
      puts "error: Failed to decrypt stored password: #{e.message}"
      Lich.log "error: CLI refresh characters failed - could not decrypt stored password for '#{}': #{e.message}"
      return 4
    end

    # Authenticate with game servers to fetch characters (like GUI does)
    puts "Authenticating with game servers..."
    Lich.log "info: Authenticating account '#{}' with game servers"
    auth_data = Lich::Common::Authentication.authenticate(
      account: ,
      password: password,
      legacy: true
    )

    unless auth_data && auth_data.is_a?(Array) && !auth_data.empty?
      puts "error: Authentication failed or no characters found"
      Lich.log "error: CLI refresh characters failed - game server authentication failed for '#{}'"
      return 3
    end

    Lich.log "info: Authentication successful - found #{auth_data.length} character(s)"

    # Determine frontend
    selected_frontend = if frontend
                          Lich.log "info: Using provided frontend: #{frontend}"
                          frontend
                        else
                          predominant = determine_predominant_frontend(yaml_file, )
                          if predominant
                            puts "Using predominant frontend: #{predominant}"
                            Lich.log "info: Using predominant frontend: #{predominant}"
                            predominant
                          else
                            prompt_for_frontend
                          end
                        end

    # Convert authentication data to character list
    character_list = Lich::Common::GUI::AccountManager.convert_auth_data_to_characters(
      auth_data,
      selected_frontend || 'stormfront'
    )

    # Defensive guard: auth_data was already confirmed non-empty above, and on the
    # only production path here (EAccess.auth's legacy branch) every entry carries
    # char_name/game_name/game_code, so this can't currently happen. Guards against
    # convert_auth_data_to_characters's filter (or the auth path) changing later and
    # silently reporting "success (0 found)".
    if character_list.empty?
      puts "error: No valid characters found after conversion"
      Lich.log "error: CLI refresh characters failed - conversion produced no characters for '#{}'"
      return 3
    end

    # Keep only characters the server reports that aren't already stored. A stored
    # character is the user's own record - they chose its frontend, custom launch,
    # and favorite flag - so a refresh must not restate it. add_or_update_account
    # keys character identity on char_name+game_code+frontend+custom_launch, and
    # every record built here carries the one selected frontend and a nil custom
    # launch, so handing it a stored character under any other frontend or launch
    # config would append a second entry rather than match the existing one.
    new_characters = reject_stored_characters(
      character_list,
      yaml_data['accounts'][]['characters']
    )

    if new_characters.empty?
      puts "success: Characters refreshed for account '#{}' (no new characters, #{character_list.length} found)"
      Lich.log "info: Characters refreshed for account '#{}' - no new characters (#{character_list.length} found)"
      return 0
    end

    # Merge into existing account using AccountManager (preserves other data)
    if Lich::Common::GUI::AccountManager.(data_dir, , password, new_characters)
      puts "success: Characters refreshed for account '#{}' (#{new_characters.length} added, #{character_list.length} found)"
      Lich.log "info: Characters refreshed successfully for account '#{}' (#{new_characters.length} added, #{character_list.length} found)"
      if selected_frontend.nil? || selected_frontend.empty?
        puts "note: No frontend selected - defaulted to stormfront, rerun with --frontend to change it"
        Lich.log "warning: No frontend selected for account '#{}' - characters stored with frontend defaulted to stormfront"
      end
      0
    else
      puts "error: Failed to save characters"
      Lich.log "error: CLI refresh characters failed - could not save account '#{}'"
      1
    end
  rescue StandardError => e
    # CRITICAL: Only log e.message, NEVER log password values
    puts "error: #{e.message}"
    Lich.log "error: CLI refresh characters failed for '#{}': #{e.message}"
    1
  end
end

.reject_stored_characters(fetched, stored) ⇒ Array<Hash>

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Drops any freshly-fetched character already represented in the account's stored records, so a refresh only ever appends genuinely new characters.

Identity here is char_name + game_code, deliberately coarser than the char_name + game_code + frontend + custom_launch that add_or_update_account uses. A single character legitimately has several stored records - one per frontend or custom launch config the user set up - and all of them describe a character the server has already told us about, so any of them is enough to consider it known.

Parameters:

  • fetched (Array<Hash>) —

    characters built from the auth response (symbol keys)

  • stored (Array<Hash>, nil) —

    the account's entry.yaml characters (string keys)

Returns:

  • (Array<Hash>) —

    the subset of fetched not already present in stored



535
536
537
538
539
# File 'documented/common/authentication/cli_password.rb', line 535

def self.reject_stored_characters(fetched, stored)
  known = (stored || []).map { |char| [normalize_char_name(char['char_name']), char['game_code']] }

  fetched.reject { |char| known.include?([normalize_char_name(char[:char_name]), char[:game_code]]) }
end

.validate_master_password_available(data_dir: DATA_DIR) ⇒ Boolean

Validates if master password is available in keychain for enhanced mode Checks: YAML exists, validation test present, keychain available with password

Parameters:

  • data_dir (String) (defaults to: DATA_DIR) —

    directory containing entry.yaml (defaults to DATA_DIR)

Returns:

  • (Boolean) —

    true if ready for operations, false if issues found



612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
# File 'documented/common/authentication/cli_password.rb', line 612

def self.validate_master_password_available(data_dir: DATA_DIR)
  yaml_file = Lich::Common::Authentication::EntryStore.yaml_file_path(data_dir)

  unless File.exist?(yaml_file)
    puts "error: entry.yaml not found"
    return false
  end

  begin
    yaml_data = YAML.safe_load_file(yaml_file, permitted_classes: [Symbol])
    encryption_mode = (yaml_data['encryption_mode'] || 'plaintext').to_sym

    # Non-enhanced modes don't need master password
    return true unless encryption_mode == :enhanced

    # Check if validation test exists (indicator of Enhanced mode setup)
    unless yaml_data['master_password_validation_test']
      puts "error: No validation test found in entry.yaml"
      puts "Master password recovery may be needed"
      return false
    end

    # Check if keychain is available and has the password
    unless Lich::Common::GUI::MasterPasswordManager.keychain_available?
      puts "error: Keychain not available on this system"
      return false
    end

    master_password = Lich::Common::GUI::MasterPasswordManager.retrieve_master_password
    if master_password.nil? || master_password.empty?
      puts "error: Master password not found in keychain"
      puts "Use: lich --recover-master-password"
      puts "     to restore the master password from your accounts"
      Lich.log "info: Master password validation failed - keychain missing, user can recover"
      return false
    end

    true
  rescue StandardError => e
    Lich.log "error: Master password validation failed: #{e.message}"
    false
  end
end