Class: Lich::BundlerRecovery

Inherits:
Object
  • Object
show all
Defined in:
documented/bundler_recovery.rb

Overview

Recovers missing non-GTK runtime gems on macOS. Bundler resolves and builds in Lich's temporary directory first. A detached helper then promotes only the missing gems and their unavailable runtime dependencies into Gem.dir, rolling back every touched gem if validation fails.

Defined Under Namespace

Classes: Result

Constant Summary collapse

LOG_FILENAME =
'lich5-bundler-recovery.log'
LOCK_FILENAME =
'.lich-bundler-recovery.lock'
EXCLUDED_GROUPS =

Bundler dependency groups excluded from Bundler recovery staging.

Examples:

EXCLUDED_GROUPS #=> ["gtk", "development", "vscode", "profanity"]
%w[gtk development vscode profanity].freeze
NATIVE_DEFAULT_GEMS =

Native gems requiring build tools and development headers during recovery.

Examples:

NATIVE_DEFAULT_GEMS #=> ["ox", "sqlite3"]
%w[ox sqlite3].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(lich_dir:, gem_home: Gem.dir, helper_launcher: nil, installer: nil) ⇒ void

Creates a new recovery manager for staging and promoting gems on macOS.

Parameters:

  • lich_dir (String)

    the path to the Lich installation directory

  • gem_home (String) (defaults to: Gem.dir)

    the target gem home directory (defaults to Gem.dir)

  • helper_launcher (Proc, nil) (defaults to: nil)

    a callable for launching the macOS helper process; if nil, uses #launch_macos_helper

  • installer (Proc, nil) (defaults to: nil)

    a callable for installing gems; if nil, uses #install_gem



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

def initialize(lich_dir:, gem_home: Gem.dir, helper_launcher: nil, installer: nil)
  @lich_dir = File.expand_path(lich_dir)
  @gem_home = File.expand_path(gem_home)
  @helper_launcher = helper_launcher || method(:launch_macos_helper)
  @installer = installer || method(:install_gem)
end

Class Method Details

.log_helper_failure(payload_or_path, error) ⇒ void

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.

This method returns an undefined value.

Logs a failure from the macOS gem promotion helper process to the recovery log.

Gracefully handles missing or unparseable payload data by falling back to a default temporary directory. If logging fails, the error is silently ignored.

Parameters:

  • payload_or_path (Hash, String)

    the parsed JSON payload (Hash) or path to the payload file (String)

  • error (StandardError)

    the error raised by the helper



70
71
72
73
74
75
76
# File 'documented/bundler_recovery.rb', line 70

def log_helper_failure(payload_or_path, error)
  payload = helper_payload(payload_or_path)
  path = File.join(payload.fetch('temp_dir', helper_temp_dir(payload_or_path)), LOG_FILENAME)
  File.open(path, 'a') { |file| file.puts("[#{Time.now}] macOS gem promotion failed\n  #{error.class}: #{error.message}\n") }
rescue StandardError
  nil
end

.run_macos_replacement(payload_path) ⇒ Object

Runs after the Lich process exits. The helper owns promotion, rollback, cleanup, and restart so the running Ruby never changes its own gems.



50
51
52
53
54
55
56
57
58
59
# File 'documented/bundler_recovery.rb', line 50

def run_macos_replacement(payload_path)
  payload = nil
  payload = JSON.parse(File.read(payload_path))
  new(lich_dir: payload.fetch('lich_dir'), gem_home: payload.fetch('gem_home'))
    .send(:run_macos_replacement!, payload, payload_path: payload_path)
  true
rescue StandardError => e
  log_helper_failure(payload || payload_path, e)
  false
end

.supported?Boolean

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.

Returns true if the current Ruby platform is macOS.

Returns:

  • (Boolean)


44
45
46
# File 'documented/bundler_recovery.rb', line 44

def supported?
  RUBY_PLATFORM.match?(/darwin/)
end

Instance Method Details

#preflight(missing) ⇒ String?

Validates prerequisites for macOS Bundler recovery.

Checks for macOS runtime support, required files and executables, and (when promoting native gems) development headers and build tools. Returns an error message if any check fails, or nil if all checks pass.

Examples:

recovery.preflight('ox') #=> nil (if all checks pass)
recovery.preflight('ox') #=> "Xcode Command Line Tools are required to build native Ruby gems" (if clang is missing)

Parameters:

  • missing (String, Array<String>)

    the gem name(s) being recovered

Returns:

  • (String, nil)

    error message if validation fails, nil if successful



123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'documented/bundler_recovery.rb', line 123

def preflight(missing)
  return 'macOS Bundler recovery is not supported by this Ruby runtime' unless self.class.supported?
  return "Gemfile not found at #{gemfile}" unless File.file?(gemfile)
  return "Ruby executable is not available at #{Gem.ruby}" unless File.executable?(Gem.ruby)
  return 'Bundler is not available from this Ruby runtime' unless command_available?(Gem.ruby, '-S', 'bundle', '--version')
  return nil unless (Array(missing) & NATIVE_DEFAULT_GEMS).any?

  return "Ruby development headers are not available at #{ruby_headers}" unless File.directory?(ruby_headers)
  return 'Xcode Command Line Tools are required to build native Ruby gems' unless command_available?('xcrun', '--find', 'clang')
  return 'make is required to build native Ruby gems' unless command_available?('make', '--version')

  nil
end

#recover(missing) ⇒ Object

Stages a complete resolver result, but promotes only the requested gems and runtime dependencies not already satisfiable in the real Gem.dir.



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

def recover(missing)
  reason = preflight(missing)
  return failure(reason) if reason

  FileUtils.mkdir_p(temp_dir)
  work_dir = Dir.mktmpdir('lich-bundler-recovery-', temp_dir)
  staging_path = File.join(work_dir, 'staging')
  staged_gemfile, frozen = stage_gemfile_files(staging_path)
  stdout, stderr, status = Open3.capture3(bundler_environment(staging_path, staged_gemfile, frozen: frozen),
                                          Gem.ruby, '-S', 'bundle', 'install', chdir: @lich_dir)
  transcript = "#{stdout}#{stderr}"
  write_transcript(transcript, status.success?)
  return failure("Bundler install failed (see #{log_path})") unless status.success?

  packages = promotion_packages(missing, staging_path)
  payload_path = File.join(work_dir, 'macos-gem-promotion.json')
  File.write(payload_path, JSON.generate(promotion_payload(work_dir, packages)))
  @helper_launcher.call(payload_path)
  work_dir = nil # The helper cleans its verified staging data after promotion.
  Result.new(log_path: log_path, restart_required: true)
rescue StandardError => e
  failure("#{e.class}: #{e.message}")
ensure
  FileUtils.rm_rf(work_dir) if defined?(work_dir) && work_dir && Dir.exist?(work_dir)
end