Module: Lich::GemCheck

Defined in:
documented/gemcheck.rb

Overview

Verifies bundled gems are installed at Lich startup and alerts the user via a native OS dialog (with a log-file fallback) when any are missing. Runs once during boot, before scripts load.

Defined Under Namespace

Classes: ConsentError

Constant Summary collapse

WINDOWS_MESSAGE =

Alert message shown to Windows users when required gems are missing.

Instructs the user to update to the latest Ruby version using the Ruby4Lich5 installer.

Returns:

"You're missing required Ruby gems!\n\n" \
"Please update to the latest Ruby version using the\n" \
"Ruby4Lich5 installer."
UNIX_MESSAGE =

Alert message shown to Unix/Linux/macOS users when required gems are missing.

Instructs the user to run bundle install from the Lich5 folder.

Returns:

"You're missing required Ruby gems!\n\n" \
"Please run 'bundle install' from your Lich5 folder."
TITLE =
'Lich5: Missing Ruby Gems'
RELEASE_URL =

URL to the latest Lich5 release on GitHub.

Shown to Windows users in the missing-gem alert dialog as the download link when they choose to open it.

Returns:

'https://github.com/elanthia-online/lich-5/releases/latest'
LOG_FILENAME =
'lich5-missing-gems.log'
120
BUNDLER_RECOVERY_TIMEOUT_SECONDS =
120
ALERT_TIMEOUT_SECONDS =
120

Class Method Summary collapse

Class Method Details

.alert(missing: [], groups: [:default], error: nil) ⇒ void

This method returns an undefined value.

Parameters:

  • missing (Array<String>) (defaults to: []) —

    gem names identified by our detector

  • groups (Array<Symbol>) (defaults to: [:default]) —

    groups being verified

  • error (Exception, nil) (defaults to: nil) —

    the Bundler or require exception, if any



311
312
313
314
315
316
317
318
319
# File 'documented/gemcheck.rb', line 311

def alert(missing: [], groups: [:default], error: nil)
  write_log(missing: missing, groups: groups, error: error)
  body = build_alert_body(missing, error)
  case RUBY_PLATFORM
  when /mswin|mingw|cygwin/ then alert_windows(body)
  when /darwin/             then alert_macos(body)
  else                           alert_linux(body)
  end
end

.alert_linux(body) ⇒ void

This method returns an undefined value.

Reports missing gems on stderr, and additionally in a GUI dialog when there is no terminal that report could have reached.

The dialog is only ever waited on, never merely shown: a launcher start has no other channel, so run_with_timeout keeps the process alive long enough for the dialog to be read. From a terminal that wait is what makes the failure look like a hang -- the dialog is easily missed (another workspace, behind other windows, or not rendering at all), it blocks for ALERT_TIMEOUT_SECONDS, and the child's output is discarded, so the user waits two minutes and never learns why. Worst for the early-exit CLI commands (--help, --version, --active-sessions, ...): they are dispatched well after this check runs, so while it blocks they cannot be reached at all.

Both streams are probed because a terminal launch may redirect either one.

Parameters:



551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
# File 'documented/gemcheck.rb', line 551

def alert_linux(body)
  # Always say it on stderr, whether or not a dialog follows. It costs
  # nothing, it is the whole message rather than a pointer to it, and when
  # stderr is captured (a launcher's output, a service log) it is the only
  # record that survives -- the dialog text goes nowhere else.
  warn("!!ALERT!! #{body}")

  # Only wait on a dialog when there was no terminal to have said it in.
  return if $stdout.isatty || $stderr.isatty

  if cmd_available?('zenity')
    run_with_timeout(['zenity', '--info', '--title', TITLE, '--text', body], ALERT_TIMEOUT_SECONDS)
  elsif cmd_available?('kdialog')
    run_with_timeout(['kdialog', '--title', TITLE, '--msgbox', body], ALERT_TIMEOUT_SECONDS)
  elsif cmd_available?('xmessage')
    run_with_timeout(['xmessage', '-center', body], ALERT_TIMEOUT_SECONDS)
  end
  # No dialog tool available needs no fallback branch any more -- the
  # unconditional warn above is the fallback.
end

.alert_macos(body) ⇒ void

This method returns an undefined value.

Parameters:



515
516
517
518
519
520
521
522
523
524
# File 'documented/gemcheck.rb', line 515

def alert_macos(body)
  script = %(display dialog #{macos_dialog_body(body)} ) +
           %(with title #{TITLE.inspect} ) +
           %(buttons {"OK"} default button "OK" with icon caution)
  IO.popen(['osascript', '-'], 'r+') do |io|
    io.write(script)
    io.close_write
    io.read
  end
end

.alert_windows(body) ⇒ void

This method returns an undefined value.

Parameters:



505
506
507
508
509
510
511
# File 'documented/gemcheck.rb', line 505

def alert_windows(body)
  require 'win32ole'
  shell = WIN32OLE.new('WScript.Shell')
  result = shell.Popup("#{body}\nClick OK to open the download page.",
                       ALERT_TIMEOUT_SECONDS, TITLE, 1 + 64) # OK/Cancel + Information icon
  shell.Run(RELEASE_URL) if result == 1
end

.all_groups ⇒ Array<Symbol>

Returns all groups declared in the Gemfile.

Returns:

  • (Array<Symbol>) —

    all groups declared in the Gemfile



301
302
303
304
305
# File 'documented/gemcheck.rb', line 301

def all_groups
  Bundler.definition.groups
rescue StandardError
  [:default]
end

.build_alert_body(missing, error) ⇒ String

Composes the alert dialog body: platform message + a bulleted list of detected missing gems, plus the underlying error when one was captured. The error is always shown when present: a failed require 'gtk3' may mean a native DLL failed to load even though every gem is installed, so hiding the message behind the missing-gems list misdiagnoses the fault.

Parameters:

  • missing (Array<String>)
  • error (Exception, nil)

Returns:



337
338
339
340
341
342
343
344
345
346
347
# File 'documented/gemcheck.rb', line 337

def build_alert_body(missing, error)
  parts = [message]
  if missing.any?
    parts << "Missing gems:\n  - #{missing.join("\n  - ")}"
  end
  if error
    parts << "Underlying error:\n  #{error.message.lines.first.to_s.strip}"
  end
  parts << "See #{File.join(TEMP_DIR, LOG_FILENAME)} for details." if defined?(TEMP_DIR)
  parts.join("\n\n")
end

.build_recovery_prompt(units) ⇒ String

Parameters:

  • units (Array<Hash>) —

    validated manifest recovery units

Returns:



243
244
245
246
247
248
249
250
251
252
# File 'documented/gemcheck.rb', line 243

def build_recovery_prompt(units)
  listed_units = Array(units).map do |unit|
    members = Array(unit['members'])
    details = members.length > 1 ? ": #{members.join(', ')}" : ''
    "  - #{recovery_unit_label(unit)}#{details}"
  end
  "Required Ruby gems are not installed:\n#{listed_units.join("\n")}\n\n" \
    "Lich can download and install the approved, hash-verified packages now.\n\n" \
    'Install now?'
end

.bundler_recovery_supported?(groups) ⇒ Boolean

The initial non-Windows recovery is deliberately macOS-only and only covers default runtime gems. GTK remains outside this Bundler path.

Parameters:

  • groups (Array<Symbol>)

Returns:

  • (Boolean)


123
124
125
# File 'documented/gemcheck.rb', line 123

def bundler_recovery_supported?(groups)
  BundlerRecovery.supported? && Array(groups).map(&:to_sym) == [:default]
end

.cmd_available?(cmd) ⇒ Boolean

Parameters:

  • cmd (String) —

    executable name to probe

Returns:

  • (Boolean)


574
575
576
# File 'documented/gemcheck.rb', line 574

def cmd_available?(cmd)
  system('which', cmd, out: File::NULL, err: File::NULL)
end

.configure_gemfile! ⇒ void

This method returns an undefined value.

Ensures Bundler resolves Lich's Gemfile even when the app is launched from another working directory, such as macOS app launch from /.



103
104
105
106
107
108
109
# File 'documented/gemcheck.rb', line 103

def configure_gemfile!
  return if ENV['BUNDLE_GEMFILE'] && !ENV['BUNDLE_GEMFILE'].empty?
  return unless defined?(LICH_DIR)

  gemfile = File.join(LICH_DIR, 'Gemfile')
  ENV['BUNDLE_GEMFILE'] = gemfile if File.file?(gemfile)
end

.confirm_bundler_recovery(gem_names) ⇒ Symbol

Returns :approved, :declined, :timed_out, or :unavailable.

Parameters:

Returns:

  • (Symbol) —

    :approved, :declined, :timed_out, or :unavailable



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
# File 'documented/gemcheck.rb', line 219

def confirm_bundler_recovery(gem_names)
  return :unavailable unless BundlerRecovery.supported?

  body = "Required Ruby gems are not installed:\n#{Array(gem_names).map { |name| "  - #{name}" }.join("\n")}\n\n" \
         "Lich can install the non-GTK runtime bundle to its private directory now.\n" \
         "Your Gemfile and Gemfile.lock will not be changed.\n\nInstall now?"
  script = %(display dialog #{macos_dialog_body(body)} with title #{TITLE.inspect} ) +
           %(buttons {"Install", "Cancel"} default button "Install" with icon caution ) +
           "giving up after #{BUNDLER_RECOVERY_TIMEOUT_SECONDS}"
  output = IO.popen(['osascript', '-'], 'r+') do |io|
    io.write(script)
    io.close_write
    io.read
  end
  return :timed_out if output.include?('gave up:true')
  return :approved if output.include?('button returned:Install')

  :declined
rescue StandardError
  :unavailable
end

.confirm_recovery_units(units) ⇒ Symbol

Returns :approved, :declined, or :unavailable.

Parameters:

  • units (Array<Hash>) —

    validated manifest recovery units

Returns:

  • (Symbol) —

    :approved, :declined, or :unavailable



208
209
210
211
212
213
214
215
# File 'documented/gemcheck.rb', line 208

def confirm_recovery_units(units)
  return :unavailable unless self_healing_supported?

  body = build_recovery_prompt(units)
  confirm_windows(body)
rescue StandardError
  :unavailable
end

.confirm_windows(body) ⇒ Symbol

Returns :approved, :declined, or :timed_out.

Parameters:

Returns:

  • (Symbol) —

    :approved, :declined, or :timed_out



466
467
468
469
470
471
472
# File 'documented/gemcheck.rb', line 466

def confirm_windows(body)
  result = windows_popup(body, 4 + 32) # Yes/No buttons + question icon
  return :approved if result == 6 # Yes
  return :timed_out if result == -1 # WScript Popup timeout

  :declined
end

Returns loggable reason for not installing.

Parameters:

  • decision (Symbol) —

    consent dialog outcome

Returns:

  • (String) —

    loggable reason for not installing



199
200
201
202
203
204
# File 'documented/gemcheck.rb', line 199

def consent_failure_reason(decision)
  return 'user consent not available' if decision == :unavailable
  return 'user consent timed out' if decision == :timed_out

  'user declined installation'
end

.dependency_report(groups) ⇒ String

Builds a per-dependency status line for every gem in the requested groups: name, requirement, and whether it's installed. This is the single most useful piece of debug output when the detector disagrees with Bundler.

Parameters:

  • groups (Array<Symbol>)

Returns:



442
443
444
445
446
447
448
449
450
451
452
453
# File 'documented/gemcheck.rb', line 442

def dependency_report(groups)
  deps = Bundler.definition.current_dependencies.select do |dep|
    (dep.groups & groups).any?
  end
  return '(none)' if deps.empty?

  deps.sort_by(&:name).map do |dep|
    installed = Gem::Specification.find_all_by_name(dep.name, dep.requirement)
    status = installed.any? ? "OK (#{installed.map(&:version).join(', ')})" : 'MISSING'
    "#{dep.name.ljust(24)} #{dep.requirement.to_s.ljust(20)} #{status}"
  end.join("\n")
end

.macos_dialog_body(body) ⇒ String

Returns AppleScript expression retaining each line break.

Parameters:

Returns:

  • (String) —

    AppleScript expression retaining each line break



528
529
530
# File 'documented/gemcheck.rb', line 528

def macos_dialog_body(body)
  body.split("\n").map(&:inspect).join(' & return & ')
end

.message ⇒ String

Returns the message appropriate for the current platform.

Returns:

  • (String) —

    the message appropriate for the current platform



322
323
324
325
326
327
# File 'documented/gemcheck.rb', line 322

def message
  case RUBY_PLATFORM
  when /mswin|mingw|cygwin/ then WINDOWS_MESSAGE
  else                           UNIX_MESSAGE
  end
end

.missing_gems(groups = [:default]) ⇒ Array<String>

Names of gems declared in the Gemfile that are not installed at any version satisfying the declared requirement, scoped to the given groups. Relies on installed Gem::Specifications; git/path-sourced gems are not detected here (the current Gemfile has none).

Parameters:

  • groups (Array<Symbol>) (defaults to: [:default]) —

    groups being verified

Returns:

  • (Array<String>) —

    sorted, unique gem names



290
291
292
293
294
295
296
297
298
# File 'documented/gemcheck.rb', line 290

def missing_gems(groups = [:default])
  Bundler.definition.current_dependencies.select do |dep|
    (dep.groups & groups).any?
  end.reject do |dep|
    Gem::Specification.find_all_by_name(dep.name, dep.requirement).any?
  end.map(&:name).sort.uniq
rescue StandardError
  []
end

.notice_windows(body) ⇒ void

This method returns an undefined value.

Parameters:



498
499
500
501
# File 'documented/gemcheck.rb', line 498

def notice_windows(body)
  require 'win32ole'
  WIN32OLE.new('WScript.Shell').Popup(body, CONSENT_TIMEOUT_SECONDS, TITLE, 64) # OK + information icon
end

.recover_with_bundler_consent!(gem_names, groups: [:default]) ⇒ BundlerRecovery::Result?

Runs a consented Bundler repair for the default non-GTK runtime gems on macOS. The recovery object uses a shipped lockfile when present, or resolves only within staging before making the private RubyGems home active.

Parameters:

  • gem_names (Array<String>)
  • groups (Array<Symbol>) (defaults to: [:default])

Returns:



166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'documented/gemcheck.rb', line 166

def recover_with_bundler_consent!(gem_names, groups: [:default])
  recovery = BundlerRecovery.new(lich_dir: LICH_DIR)
  if (reason = recovery.preflight(gem_names))
    return BundlerRecovery::Result.new(error: reason)
  end

  decision = confirm_bundler_recovery(gem_names)
  unless decision == :approved
    report_bundler_consent_failure(gem_names, groups, consent_failure_reason(decision))
    return nil
  end

  recovery.recover(gem_names)
end

.recover_with_consent!(gem_names, force: false, groups: [:default]) ⇒ DependencyRecovery::Result?

Fetches and validates the manifest, requests consent for each affected recovery unit, then performs the download and installation only after all units were approved. Returns nil when consent was declined or unavailable; that case is already logged and presented to the user.

Parameters:

  • gem_names (Array<String>) —

    names to recover

  • force (Boolean) (defaults to: false) —

    reinstall an already registered manifest package

  • groups (Array<Symbol>) (defaults to: [:default]) —

    dependency groups being recovered

Returns:



149
150
151
152
153
154
155
156
157
# File 'documented/gemcheck.rb', line 149

def recover_with_consent!(gem_names, force: false, groups: [:default])
  recovery = DependencyRecovery.new
  plan = recovery.recovery_plan(gem_names)
  return DependencyRecovery::Result.new(installed_gems: [], error: plan.error) unless plan.success?
  return nil unless recovery_units_approved?(plan.units, groups)

  write_recovery_log(missing: gem_names, groups: groups, units: plan.units)
  recovery.recover(gem_names, force: force, plan: plan)
end

.recovery_unit_label(unit) ⇒ String

Parameters:

  • unit (Hash) —

    validated manifest recovery unit

Returns:



256
257
258
259
260
261
262
# File 'documented/gemcheck.rb', line 256

def recovery_unit_label(unit)
  members = Array(unit['members'])
  return "#{members.first} gem" if members.length == 1
  return 'GTK3 runtime bundle' if unit['id'] == 'gtk3-runtime'

  "#{unit.fetch('id').tr('-', ' ')} bundle"
end

.recovery_units_approved?(units, groups) ⇒ Boolean

Requests one consent decision for every affected unit before any artifact download or installation begins. This prevents a declined bundle from leaving an earlier approved unit partially installed.

Parameters:

  • units (Array<Hash>) —

    validated manifest units

  • groups (Array<Symbol>) —

    dependency groups being recovered

Returns:

  • (Boolean)


188
189
190
191
192
193
194
195
# File 'documented/gemcheck.rb', line 188

def recovery_units_approved?(units, groups)
  decision = confirm_recovery_units(units)
  return true if decision == :approved

  reason = consent_failure_reason(decision)
  report_consent_failure(units, groups, reason)
  false
end

This method returns an undefined value.

Parameters:

  • missing (Array<String>)
  • groups (Array<Symbol>)
  • reason (String)


279
280
281
282
# File 'documented/gemcheck.rb', line 279

def report_bundler_consent_failure(missing, groups, reason)
  write_log(missing: missing, groups: groups, error: ConsentError.new(reason))
  show_notice("Required gem#{'s' if missing.length != 1} #{missing.join(', ')} not installed. Exiting.")
end

This method returns an undefined value.

Parameters:

  • units (Array<Hash>) —

    recovery units the user did not approve

  • groups (Array<Symbol>) —

    dependency groups being recovered

  • reason (String) —

    user-decision or UI-availability reason



268
269
270
271
272
273
# File 'documented/gemcheck.rb', line 268

def report_consent_failure(units, groups, reason)
  error = ConsentError.new(reason)
  missing = units.flat_map { |unit| Array(unit['members']) }.uniq
  write_log(missing: missing, groups: groups, error: error)
  show_notice("Required gem#{'s' if missing.length != 1} #{missing.join(', ')} not installed. Exiting.")
end

.run_with_timeout(cmd, timeout) ⇒ Boolean

Runs an external command that would otherwise block indefinitely (a GUI dialog with no one present to dismiss it, e.g. a dead or forwarded X display) and forcibly reclaims control once the timeout elapses. Wrapping a blocking Kernel#system call in Timeout can't do this safely, since the spawned child keeps running as an orphan; spawning it ourselves lets us kill it directly.

Parameters:

  • cmd (Array<String>) —

    command and arguments for Process.spawn

  • timeout (Integer) —

    seconds to wait before killing the process

Returns:

  • (Boolean) —

    whether the command exited on its own within the timeout



587
588
589
590
591
592
593
594
595
# File 'documented/gemcheck.rb', line 587

def run_with_timeout(cmd, timeout)
  pid = Process.spawn(*cmd, out: File::NULL, err: File::NULL)
  Timeout.timeout(timeout) { Process.wait(pid) }
  true
rescue Timeout::Error
  Process.kill('TERM', pid)
  Process.wait(pid)
  false
end

.safe_call { ... } ⇒ Object, String

Wraps a block, returning its result or a placeholder string on error.

Yields:

  • the value to compute

Returns:



458
459
460
461
462
# File 'documented/gemcheck.rb', line 458

def safe_call
  yield
rescue StandardError => e
  "(unavailable: #{e.class}: #{e.message})"
end

.self_healing_supported? ⇒ Boolean

Ruby4Lich5 currently publishes and validates recovery artifacts only for the Windows runtime. Other platforms retain the ordinary missing-gem warning and never fetch the recovery manifest.

Returns:

  • (Boolean)


115
116
117
# File 'documented/gemcheck.rb', line 115

def self_healing_supported?
  Gem.win_platform?
end

.show_notice(body) ⇒ void

This method returns an undefined value.

Shows an error without the normal missing-gem alert's release-page link.

Parameters:



486
487
488
489
490
491
492
493
494
# File 'documented/gemcheck.rb', line 486

def show_notice(body)
  case RUBY_PLATFORM
  when /mswin|mingw|cygwin/ then notice_windows(body)
  when /darwin/             then alert_macos(body)
  else                           alert_linux(body)
  end
rescue StandardError
  warn "!!ALERT!! #{body}"
end

.startup_groups(argv = ARGV) ⇒ Array<Symbol>

Chooses the dependency groups that must be present before normal startup. Only Windows verifies GTK here, because Ruby4Lich5 publishes a Windows recovery unit for it. On every platform, init.rb treats GTK as required unless the user explicitly passes --no-gtk or --no-gui.

Parameters:

  • argv (Array<String>) (defaults to: ARGV) —

    command-line arguments

Returns:

  • (Array<Symbol>)


134
135
136
137
138
# File 'documented/gemcheck.rb', line 134

def startup_groups(argv = ARGV)
  groups = [:default]
  groups << :gtk if self_healing_supported? && !Array(argv).any? { |arg| arg.match?(/^--no-(?:gtk|gui)$/i) }
  groups
end

.verify!(*groups) ⇒ void

This method returns an undefined value.

Verifies every gem required by the requested Bundler groups is installed, alerting the user (native OS dialog, with a log-file fallback) and exiting when any remain missing after one manifest-backed recovery attempt. This remains a presence check only: it does not call Bundler.setup or lock the load path, leaving scripts free to require gems they install at runtime.

Parameters:

  • groups (Array<Symbol>) —

    Bundler groups to verify



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
# File 'documented/gemcheck.rb', line 59

def verify!(*groups)
  groups = [:default] if groups.empty?
  configure_gemfile!

  missing = missing_gems(groups)
  return if missing.empty?

  if self_healing_supported?
    result = recover_with_consent!(missing, groups: groups)
    exit 1 unless result
    exit 0 if result.restart_required

    if result.success?
      missing = missing_gems(groups)
      return if missing.empty?

      alert(missing: missing, groups: groups)
    else
      alert(missing: missing, groups: groups,
            error: DependencyRecovery::Error.new(result.error))
    end
    exit 1
  end

  if bundler_recovery_supported?(groups)
    result = recover_with_bundler_consent!(missing, groups: groups)
    exit 1 unless result

    if result.success?
      write_bundler_recovery_log(missing: missing, groups: groups, result: result)
      exit(result.restart_required ? 0 : 1)
    end

    alert(missing: missing, groups: groups, error: DependencyRecovery::Error.new(result.error))
    exit 1
  else
    alert(missing: missing, groups: groups)
    exit 1
  end
end

.windows_popup(body, flags) ⇒ Integer

Returns WScript Popup result code.

Parameters:

  • body (String)
  • flags (Integer) —

    WScript Popup button and icon flags

Returns:

  • (Integer) —

    WScript Popup result code



477
478
479
480
481
# File 'documented/gemcheck.rb', line 477

def windows_popup(body, flags)
  require 'win32ole'
  shell = WIN32OLE.new('WScript.Shell')
  shell.Popup(body, CONSENT_TIMEOUT_SECONDS, TITLE, flags)
end

.write_bundler_recovery_log(missing:, groups:, result:) ⇒ void

This method returns an undefined value.

Parameters:



363
364
365
366
# File 'documented/gemcheck.rb', line 363

def write_bundler_recovery_log(missing:, groups:, result:)
  write_log(missing: missing, groups: groups, event: 'Bundler recovery',
            recovery_note: "Private non-GTK bundle staged and activated. Details: #{result.log_path}")
end

.write_log(missing: [], groups: [:default], error: nil, event: 'failure', recovery_units: nil, recovery_note: nil) ⇒ void

This method returns an undefined value.

Parameters:

  • missing (Array<String>) (defaults to: [])
  • groups (Array<Symbol>) (defaults to: [:default])
  • error (Exception, nil) (defaults to: nil)
  • event (String) (defaults to: 'failure') —

    log event name

  • recovery_units (Array<Hash>, nil) (defaults to: nil) —

    approved manifest units

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

    additional approved recovery detail



375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
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
# File 'documented/gemcheck.rb', line 375

def write_log(missing: [], groups: [:default], error: nil, event: 'failure', recovery_units: nil, recovery_note: nil)
  log_path = File.join(TEMP_DIR, LOG_FILENAME)
  # verify! can run before init.rb creates TEMP_DIR (fresh install), so
  # ensure the directory exists or the alert would cite a log we never wrote.
  Dir.mkdir(TEMP_DIR) unless File.exist?(TEMP_DIR)
  File.open(log_path, 'a') do |f|
    f.puts "[#{Time.now}] Lich5 GemCheck #{event}"
    f.puts message.gsub(/^/, '  ') if event == 'failure'
    f.puts

    if recovery_units
      f.puts '  Approved manifest recovery units:'
      recovery_units.each do |unit|
        f.puts "    - #{recovery_unit_label(unit)}: #{Array(unit['members']).join(', ')}"
      end
      f.puts
    end

    if recovery_note
      f.puts "  Recovery: #{recovery_note}"
      f.puts
    end

    f.puts '  Diagnostics:'
    f.puts "    Ruby:            #{RUBY_DESCRIPTION}"
    f.puts "    Bundler:         #{safe_call { Bundler::VERSION }}"
    f.puts "    Gemfile:         #{safe_call { Bundler.default_gemfile }}"
    f.puts "    Lockfile:        #{safe_call { Bundler.default_lockfile }}"
    f.puts "    Working dir:     #{Dir.pwd}"
    f.puts "    Groups checked:  #{groups.inspect}"
    f.puts "    All groups:      #{all_groups.inspect}"
    f.puts

    if missing.any?
      f.puts '  Missing gems (detected):'
      missing.each { |name| f.puts "    - #{name}" }
    else
      f.puts '  Missing gems (detected): none identified by GemCheck'
    end
    f.puts

    if error
      f.puts '  Bundler error:'
      f.puts "    Class:   #{error.class}"
      error.message.each_line { |line| f.puts "    #{line.chomp}" }
      f.puts
    end

    f.puts '  Declared dependencies in requested groups:'
    safe_call { dependency_report(groups) }.to_s.each_line do |line|
      f.puts "    #{line.chomp}"
    end
    f.puts

    f.puts "  Download: #{RELEASE_URL}" if event == 'failure' && RUBY_PLATFORM =~ /mswin|mingw|cygwin/
    f.puts
  end
rescue StandardError
  # Filesystem write failed; continue to GUI attempt.
end

.write_recovery_log(missing:, groups:, units:) ⇒ void

This method returns an undefined value.

Records a successful user-approved recovery attempt so self-healing is observable even when Lich subsequently starts without an error dialog.

Parameters:

  • missing (Array<String>)
  • groups (Array<Symbol>)
  • units (Array<Hash>)


355
356
357
# File 'documented/gemcheck.rb', line 355

def write_recovery_log(missing:, groups:, units:)
  write_log(missing: missing, groups: groups, event: 'recovery', recovery_units: units)
end