Class: Booker::Installer

Inherits:
Object
  • Object
show all
Includes:
Output
Defined in:
lib/booker/installer.rb

Constant Summary collapse

SHELLS =

shells we ship completion for, and where those scripts live

%w[zsh bash fish].freeze
ZSHRC_LINES =

what ~/.zshrc needs for a script in ~/.zsh/completion to be found

[
  "fpath=(~/.zsh/completion $fpath)",
  "autoload -Uz compinit && compinit"
].freeze
COMPLETIONS_DIR =
File.expand_path("../../completions", __dir__).freeze
BASH_COMPLETION_MARKERS =

if any of these exist, bash-completion is installed and will autoload our script out of the XDG user directory

[
  "/usr/share/bash-completion/bash_completion",
  "/etc/bash_completion",
  "/usr/local/etc/profile.d/bash_completion.sh",
  "/opt/homebrew/etc/profile.d/bash_completion.sh"
].freeze
ALL =

'all' expands to the full install list (including opt-in safari)

%w[completion config bookmarks safari].freeze

Instance Method Summary collapse

Methods included from Output

#pexit

Instance Method Details

#append_once(rcfile, lines, marker: lines, label: rcfile) ⇒ Object

append to an rc file once - install is expected to be re-runnable without stacking up duplicates. marker is what counts as "already there" when that is narrower than what gets written; label is the name shown



231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/booker/installer.rb', line 231

def append_once(rcfile, lines, marker: lines, label: rcfile)
  if File.exist?(rcfile) && File.read(rcfile).include?(marker)
    puts "#{label} already configured".grn
    return
  end

  File.open(rcfile, "a") do |f|
    f.puts "\n# Booker completion"
    f.puts lines
  end
  puts "Added completion to #{label}".grn
  yield if block_given?
end

#bash_completion_present?Boolean

Returns:

  • (Boolean)


224
225
226
# File 'lib/booker/installer.rb', line 224

def bash_completion_present?
  BASH_COMPLETION_MARKERS.any? { |marker| File.exist?(marker) }
end

#bookmark_type_label(type, color: false) ⇒ Object

the label and its color come off the browser table, so a fourth browser is a row there rather than two more branches here



311
312
313
314
315
316
# File 'lib/booker/installer.rb', line 311

def bookmark_type_label(type, color: false)
  browser = Bookmarks::BROWSERS[type]
  return "[?]" if browser.nil?

  color ? Colors.paint(browser[:label], browser[:color]) : browser[:label]
end

#clear_zsh_compdumpObject

compinit caches what it found in $fpath, so a shell started against an old dump keeps describing the completion booker just replaced. dropping the cache - zsh rebuilds it on the next start - is how a new script reaches the next shell. nothing helps the shell you ran this from, which autoloaded _booker for the life of the session, hence the unfunction advice



158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/booker/installer.rb', line 158

def clear_zsh_compdump
  base = ENV["ZDOTDIR"] || home
  dumps = Dir.glob(File.join(base, ".zcompdump*"))
  return if dumps.empty?

  dumps.each do |dump|
    File.delete(dump)
  rescue
    # a dump we cannot remove is not worth failing an install over
  end

  puts "Refreshed: ".grn + "zsh completion cache (rebuilds on next shell)"
end

#completion_script(name) ⇒ Object

read one of the shipped completion scripts (completions/ sits next to lib/, and is listed in the gemspec so it ships with the gem)



214
215
216
217
218
# File 'lib/booker/installer.rb', line 214

def completion_script(name)
  File.read(File.join(COMPLETIONS_DIR, name))
rescue Errno::ENOENT
  pexit "Failure: ".red + "completion script #{name} missing from #{COMPLETIONS_DIR}"
end

#homeObject



245
246
247
# File 'lib/booker/installer.rb', line 245

def home
  ENV["HOME"] || "/usr/local"
end

#install(args) ⇒ Object



39
40
41
42
# File 'lib/booker/installer.rb', line 39

def install(args)
  args.flat_map { |target| /^all$/i.match?(target) ? ALL : target }
    .each { |target| install_one(target) }
end

#install_bookmarksObject

locate bookmarks files, show the user, write the choice to the config. the search is Sources.discover, the same code auto-detection falls back on, so this offers exactly what booker would find on its own



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
# File 'lib/booker/installer.rb', line 260

def install_bookmarks
  puts "searching for browser bookmarks..."
  begin
    bms = Sources.discover

    if bms.empty? # no bookmarks found
      puts "Failure: ".red + "bookmarks file could not be found."
      raise
    elsif bms.length == 1
      # Auto-select if only one source found
      selected = bms.first
      puts "Found bookmark source: #{bookmark_type_label(Bookmarks.source_for(selected))} #{selected}".yel
      save_bookmarks(selected, "config file updated with your bookmarks")
    else # have user select a file
      puts "select bookmarks source: "

      # Offer "ALL" as first option if multiple sources found
      puts "0".grn + " - " + "[ALL SOURCES]".cyan + " (search across all browsers)"

      bms.each_with_index do |path, i|
        label = bookmark_type_label(Bookmarks.source_for(path), color: true)
        puts (i + 1).to_s.grn + " - " + label + " " + path
      end

      input = gets
      raise "No input provided" if input.nil?
      selection = input.chomp.to_i

      if selection == 0
        # User selected "ALL" - save array of all paths
        puts "Selected: ".yel + "All sources (#{bms.length} bookmark files)"
        Config.default.write(:bookmarks, bms)
        puts "Success: ".grn + "config file updated to search all bookmark sources"
      else
        save_bookmarks(bms[selection - 1], "config file updated with your bookmarks")
      end
    end
  rescue => e
    puts e.message
    pexit "Failure: ".red + "could not add bookmarks to config file ~/.booker"
  end
end

#install_completionObject

install completion for every supported shell present on this machine, so a zsh user who also drops into bash gets both without running install twice



62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/booker/installer.rb', line 62

def install_completion
  found, missing = SHELLS.partition { |shell| shell_present?(shell) }

  if found.empty?
    puts "Warning: ".yel + "no supported shell found (#{SHELLS.join(", ")})"
    return
  end

  found.each { |shell| install_completion_for(shell) }

  puts "Skip: ".yel + "#{missing.join(", ")} not installed" unless missing.empty?
end

#install_completion_bashObject

bash-completion (when installed) autoloads from the XDG user directory. Without it there is nothing doing the loading, so fall back to a plain directory plus a source line in ~/.bashrc.



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 'lib/booker/installer.rb', line 175

def install_completion_bash
  autoloaded = bash_completion_present?
  dir = if autoloaded
    File.join(xdg_data_home, "bash-completion", "completions")
  else
    File.join(home, ".bash_completion.d")
  end

  FileUtils.mkdir_p(dir)
  File.write(File.join(dir, "booker"), completion_script("booker.bash"))
  puts "Success: ".grn + "installed bash completion in #{dir}"

  # with bash-completion installed there is nothing left to wire up
  unless autoloaded
    append_once(
      File.join(home, ".bashrc"),
      "[ -f ~/.bash_completion.d/booker ] && . ~/.bash_completion.d/booker"
    )
  end

  puts "Run: ".yel + "source ~/.bashrc".cyan + " to activate"
rescue => e
  puts "Warning: ".yel + "could not install bash completion (#{e.message})"
end

#install_completion_fishObject

fish autoloads anything in its completions directory, so there is no rc file to edit here



202
203
204
205
206
207
208
209
210
# File 'lib/booker/installer.rb', line 202

def install_completion_fish
  dir = File.join(xdg_config_home, "fish", "completions")
  FileUtils.mkdir_p(dir)
  File.write(File.join(dir, "booker.fish"), completion_script("booker.fish"))
  puts "Success: ".grn + "installed fish completion in #{dir}"
  puts "Run: ".yel + "exec fish".cyan + " to activate"
rescue => e
  puts "Warning: ".yel + "could not install fish completion (#{e.message})"
end

#install_completion_for(shell) ⇒ Object

every shell in SHELLS has an install_completion_, so adding a fourth means adding the script, the method, and the SHELLS entry - nothing here



77
78
79
# File 'lib/booker/installer.rb', line 77

def install_completion_for(shell)
  send("install_completion_#{shell}")
end

#install_completion_zshObject



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
107
108
109
110
111
112
113
114
115
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
# File 'lib/booker/installer.rb', line 81

def install_completion_zsh
  # check if zsh is even installed for this user - capture3 raises
  # Errno::ENOENT when it is not, same as the backtick this replaces
  begin
    out, _err, _status = Open3.capture3("zsh", "-c", "echo $fpath")
    fpath = out.split(" ")
  rescue
    pexit "Failure: ".red + "zsh is probably not installed, could not find $fpath"
  end

  # Try user-writable directories first, then system directories
  user_home = home
  writable_dirs = fpath.select do |fp|
    fp.start_with?(user_home) && File.directory?(fp) && File.writable?(fp)
  end

  # If no user-writable directories, try to create one
  if writable_dirs.empty?
    user_completion_dir = File.join(user_home, ".zsh", "completion")
    begin
      # mkdir_p is happy either way, but saying "Created" every time a
      # developer reinstalls is a small lie about what just happened
      existed = File.directory?(user_completion_dir)
      FileUtils.mkdir_p(user_completion_dir)
      writable_dirs << user_completion_dir
      puts "Created user completion directory: #{user_completion_dir}".yel unless existed

      # Auto-configure .zshrc if it exists
      zshrc = File.join(user_home, ".zshrc")
      if File.exist?(zshrc)
        # the marker is the directory, not the whole block: a zshrc that
        # already puts it on $fpath is configured, however it spelled the
        # compinit call next to it
        append_once(zshrc, ZSHRC_LINES, marker: ".zsh/completion", label: "~/.zshrc") do
          puts "Run: ".yel + "source ~/.zshrc".cyan + " to activate"
        end
      else
        puts "Add this to your ~/.zshrc: ".yel + "fpath=(~/.zsh/completion $fpath)".cyan
      end
    rescue
      # Couldn't create user dir, try system dirs as fallback
    end
  end

  # Try writable directories first, then all directories as fallback
  dirs_to_try = writable_dirs + fpath.reject { |fp| writable_dirs.include?(fp) }

  success = false
  dirs_to_try.each do |fp|
    next unless File.directory?(fp)

    begin
      # nothing is autoloaded here on purpose: `zsh -c 'autoload -U
      # _booker'` loaded it into a subshell that exited on the next line.
      # clear_zsh_compdump below is what reaches a new shell
      File.write(File.join(fp, "_booker"), completion_script("_booker"))
      puts "Success: ".grn + "installed zsh autocompletion in #{fp}"
      success = true
      break
    rescue
      # Try next directory silently
    end
  end

  if success
    clear_zsh_compdump
  else
    puts "Warning: ".yel + "Could not install ZSH completion to any directory in $fpath"
    puts "Try manually: ".grn + "mkdir -p ~/.zsh/completion && booker --install zsh"
  end
end

#install_configObject



318
319
320
321
322
323
# File 'lib/booker/installer.rb', line 318

def install_config
  Config.default.write
  puts "Success: ".grn + "example config file written to ~/.booker"
rescue
  pexit "Failure: ".red + "could not write example config file to ~/.booker"
end

#install_one(target) ⇒ Object



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/booker/installer.rb', line 44

def install_one(target)
  if /comp/i.match?(target) # completion for every shell on this machine
    install_completion
  elsif (shell = SHELLS.find { |s| target.downcase.include?(s) })
    install_completion_for(shell) # completion for one named shell
  elsif /book/i.match?(target) # bookmarks installation
    install_bookmarks
  elsif /conf/i.match?(target) # default config file generation
    install_config
  elsif /safari/i.match?(target) # opt-in Safari FDA setup (macOS only)
    install_safari
  else # unknown argument passed into install
    pexit "Failure: ".red + "unknown installation option (#{target})"
  end
end

#install_safariObject

Opt-in Safari setup: walks through granting Full Disk Access so booker can read ~/Library/Safari/Bookmarks.plist. Not included in the default --install flow because it requires a TCC permission grant.



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
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
# File 'lib/booker/installer.rb', line 328

def install_safari
  plist = File.join(home, "Library/Safari/Bookmarks.plist")
  fda_url = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"

  unless RUBY_PLATFORM.include?("darwin")
    puts "Skip: ".yel + "Safari support is macOS-only."
    return
  end

  unless File.exist?(plist)
    puts "Skip: ".yel + "Safari bookmarks not found at #{plist}"
    puts "Hint: ".grn + "launch Safari at least once, then re-run."
    return
  end

  if safari_readable?(plist)
    puts "PASS: ".grn + "Safari bookmarks are already readable."
    return
  end

  puts "Safari stores bookmarks at:"
  puts "  #{plist}".cyan
  puts
  puts "That file is protected by macOS TCC. Grant Full Disk Access to one of:"
  puts
  puts "  [A] ".cyan + "Your terminal app".yel + " (simplest; inherited by any tool)"
  puts "  [B] ".cyan + "/usr/bin/plutil only".yel + " (narrower scope)"
  puts
  print "Pick [A] or [B] (default A): "
  $stdout.flush
  choice = $stdin.gets.to_s.strip.upcase
  choice = "A" if choice.empty?
  pexit "Error: ".red + "invalid choice." unless %w[A B].include?(choice)

  puts
  puts "Opening the Full Disk Access pane..."
  system("open", fda_url)
  puts

  puts "In the pane that just opened:"
  if choice == "A"
    puts "  1. Click ".yel + "+".cyan + ", add your terminal app from /Applications"
    puts "  2. Toggle it ".yel + "on".cyan
    puts "  3. ".yel + "Fully quit".cyan + " the terminal (Cmd+Q), reopen it,"
    puts "     and re-run ".yel + "booker --install safari".cyan + " to verify."
  else
    puts "  1. Click ".yel + "+".cyan
    puts "  2. Press ".yel + "Cmd+Shift+G".cyan + " (opens 'Go to Folder')".yel
    puts "  3. Type ".yel + "/usr/bin/plutil".cyan + " and press Return".yel
    puts "  4. Click ".yel + "Open".cyan + ", then toggle it ".yel + "on".cyan
    puts
    print "Press Return once you've added plutil and toggled it on... "
    $stdout.flush
    $stdin.gets

    if safari_readable?(plist)
      puts "PASS: ".grn + "Safari bookmarks are now readable."
    else
      puts "FAIL: ".red + "still can't read the bookmarks file."
      puts "Try fully quitting the terminal (Cmd+Q) and re-running."
    end
  end
end

#safari_readable?(plist) ⇒ Boolean

Returns:

  • (Boolean)


392
393
394
# File 'lib/booker/installer.rb', line 392

def safari_readable?(plist)
  system("plutil", "-lint", "-s", plist, out: File::NULL, err: File::NULL)
end

#save_bookmarks(selected, message) ⇒ Object



303
304
305
306
307
# File 'lib/booker/installer.rb', line 303

def save_bookmarks(selected, message)
  puts "Selected: ".yel + selected
  Config.default.write(:bookmarks, selected)
  puts "Success: ".grn + message
end

#shell_present?(shell) ⇒ Boolean

Picker.which, not sh -c "command -v": a fork per shell to answer a PATH question booker already knows how to answer in process

Returns:

  • (Boolean)


222
# File 'lib/booker/installer.rb', line 222

def shell_present?(shell) = !Picker.which(shell).nil?

#xdg_config_homeObject



253
254
255
# File 'lib/booker/installer.rb', line 253

def xdg_config_home
  ENV["XDG_CONFIG_HOME"] || File.join(home, ".config")
end

#xdg_data_homeObject



249
250
251
# File 'lib/booker/installer.rb', line 249

def xdg_data_home
  ENV["XDG_DATA_HOME"] || File.join(home, ".local", "share")
end