booker 1.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ # configuation - which platform we are on, how to open a link, and where the
4
+ # bookmarks live
5
+
6
+ require "yaml"
7
+
8
+ require_relative "output"
9
+
10
+ using Booker::Colors
11
+
12
+ module Booker
13
+ # detect operating system
14
+ module OS
15
+ def self.windows? = RUBY_PLATFORM.match?(/cygwin|mswin|mingw|bccwin|wince|emx/)
16
+
17
+ def self.mac? = RUBY_PLATFORM.match?(/darwin/)
18
+
19
+ def self.linux? = !(windows? || mac?)
20
+ end
21
+
22
+ # return browser (chrome) opening command
23
+ module Browser
24
+ def browse
25
+ if OS.windows?
26
+ # alternatively, start seems to work - probably check if powershell v cygwin?
27
+ '/cygdrive/c/Program\ Files\ \(x86\)/Google/Chrome/Application/chrome.exe '
28
+ elsif OS.mac?
29
+ "open "
30
+ else
31
+ # linux? is defined as "neither of the above", so there is no fourth
32
+ # branch for #browse to fall out of and return nil from
33
+ "xdg-open "
34
+ end
35
+ end
36
+
37
+ # what an explicit scheme looks like. #domain and #prep are two halves of
38
+ # one grammar - is this a url, does it already say how to fetch it - so both
39
+ # read the rule from here rather than spelling it out twice and drifting
40
+ SCHEME = /[a-z][a-z0-9+.-]*:\/\//i
41
+
42
+ # does this argument look like a website rather than a search term? an
43
+ # explicit scheme, or a bare host with a dot and an alphabetic tld. every
44
+ # tld has to work: completion inserts real bookmark urls, and the old
45
+ # (io|com|net|org|...) list sent .dev and .ai off to the search engine
46
+ def domain
47
+ %r{\A(?:#{SCHEME}\S+|(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:[/?#]\S*)?)\z}io
48
+ end
49
+
50
+ # only a bare host needs a scheme invented for it. testing for "http" alone
51
+ # sent everything else down the same branch, so a completed
52
+ # chrome://bookmarks/ came out as http://chrome://bookmarks/
53
+ def prep(url)
54
+ /\A#{SCHEME}/io.match?(url) ? url : "http://" + url
55
+ end
56
+ end
57
+
58
+ # configuration
59
+ class Config
60
+ VALID = [:searcher, :bookmarks, :browser, :picker].freeze
61
+ HOME = ENV.fetch("HOME", "/usr/local/").freeze
62
+ YAMLCONF = (HOME + "/.booker.yml").freeze
63
+
64
+ class << self
65
+ # one instance per run. Bookmarks, Picker and the CLI want the same
66
+ # answers, and one Config apiece meant parsing the yaml three times,
67
+ # discovering three times, and warning three times to say it once
68
+ def default = @default ||= new
69
+
70
+ # the memo outlives a single example, so the suite drops it between them
71
+ def reset! = @default = nil
72
+ end
73
+
74
+ def initialize
75
+ # the yaml file if there is one, and only work out the defaults when
76
+ # there is not: discovery walks every chrome profile directory to answer
77
+ # a question the config file has already answered
78
+ @config = read(YAMLCONF) || {
79
+ browser: "open ",
80
+ searcher: "https://google.com/search?q=",
81
+ bookmarks: detect_default_bookmarks
82
+ }
83
+
84
+ # prune bad config keys
85
+ @config.each_key do |k|
86
+ if !VALID.include? k.to_sym
87
+ warn "Failure:".red + " Bad key found in config file: #{k}"
88
+ exit 1
89
+ end
90
+ end
91
+ end
92
+
93
+ def detect_default_bookmarks
94
+ # Return ALL available bookmark sources (Chrome, Firefox, Safari)
95
+ all_sources = discover_all_bookmark_sources
96
+
97
+ # If we found sources, return them all; otherwise return a default single path
98
+ if all_sources.empty?
99
+ # Fallback to macOS Chrome default for backward compatibility
100
+ HOME + "/Library/Application Support/Google/Chrome/Profile 1/Bookmarks"
101
+ else
102
+ all_sources
103
+ end
104
+ end
105
+
106
+ def discover_all_bookmark_sources = Sources.discover
107
+
108
+ def read(file)
109
+ # #write emits symbol keys, so they have to be permitted on the way back
110
+ YAML.safe_load_file(file, permitted_classes: [Symbol])
111
+ rescue Errno::ENOENT
112
+ warn "Warning: ".yel +
113
+ "YAML configuration file couldn't be found. Using defaults."
114
+ warn "Suggest: ".grn + "booker --install config"
115
+ false
116
+ rescue Psych::Exception
117
+ # every psych failure means the same thing here - an unusable config to
118
+ # fall back from rather than crash on. rescuing SyntaxError alone let
119
+ # anchors through as an AliasesNotEnabled backtrace under psych 4
120
+ warn "Warning: ".red +
121
+ "YAML configuration file could not be read. Using defaults."
122
+ false
123
+ end
124
+
125
+ # used for creating and updating the default configuration
126
+ def write(k = nil, v = nil)
127
+ @config[k] = v unless k.nil? # update users yaml config file
128
+ File.write(YAMLCONF, @config.to_yaml)
129
+ end
130
+
131
+ # always an array, so callers never branch on it: configs written before
132
+ # multi-source support hold a single path string
133
+ def bookmarks = Array(@config[:bookmarks])
134
+
135
+ def searcher = @config[:searcher]
136
+
137
+ # the finder command with its flags, split with Shellwords rather than
138
+ # handed to a shell. nil means "auto detect fzf", which is why #write leaves
139
+ # it out of the defaults: #initialize exits on keys it does not know, so a
140
+ # config naming it is unreadable by a booker predating the key
141
+ def picker = @config[:picker]
142
+ end
143
+
144
+ # where browsers keep their bookmarks. one implementation, shared by the
145
+ # auto-detection Config falls back on and by `booker --install bookmarks` -
146
+ # the installer's job is to show what auto-detection would find, so a second
147
+ # copy of these paths guarantees it eventually shows something else. nothing
148
+ # here tags a source: Bookmarks.source_for reads that off the filename
149
+ module Sources
150
+ CHROME = [
151
+ "/Library/Application Support/Google/Chrome", # macOS
152
+ "/Library/Application Support/Chromium", # macOS Chromium
153
+ "/.config/google-chrome", # Linux
154
+ "/.config/chromium", # Linux Chromium
155
+ "/snap/chromium/common/chromium", # Linux (snap)
156
+ "/snap/chromium/current/.config/chromium", # Linux (snap alt)
157
+ "/AppData/Local/Google/Chrome/User Data" # Windows
158
+ ].freeze
159
+
160
+ FIREFOX = [
161
+ "/.mozilla/firefox", # Linux
162
+ "/snap/firefox/common/.mozilla/firefox", # Linux (snap)
163
+ "/Library/Application Support/Firefox", # macOS
164
+ "/AppData/Roaming/Mozilla/Firefox" # Windows
165
+ ].freeze
166
+
167
+ SAFARI = "/Library/Safari/Bookmarks.plist"
168
+
169
+ class << self
170
+ def discover = (chrome + firefox + safari).uniq
171
+
172
+ # every places.sqlite named by a profiles.ini. any Path= counts, and the
173
+ # [Install...] block firefox also writes names a profile already listed,
174
+ # hence the uniq
175
+ def profiles(ini_path, base)
176
+ File.readlines(ini_path).filter_map do |line|
177
+ key, value = line.strip.split("=", 2)
178
+ next unless key == "Path" && value
179
+
180
+ db = File.join(base, value.strip, "places.sqlite")
181
+ db if File.exist?(db)
182
+ end.uniq
183
+ end
184
+
185
+ private
186
+
187
+ def chrome
188
+ expand(CHROME).flat_map do |base|
189
+ # one level down, not "**": chrome puts Bookmarks directly inside a
190
+ # profile directory, and the rest of the tree is Cache and Service
191
+ # Worker storage - tens of thousands of files to walk for nothing. the
192
+ # second pattern covers a base that already names a profile. globbing
193
+ # relative to base keeps a metacharacter in the path itself - a
194
+ # profile named "Chrome [work]", say - out of the pattern
195
+ Dir.glob(["Bookmarks", "*/Bookmarks"], base: base)
196
+ .map { |relative| File.join(base, relative) }
197
+ .select { |file| File.file?(file) }
198
+ rescue
199
+ # Skip if we can't read this directory
200
+ []
201
+ end
202
+ end
203
+
204
+ def firefox
205
+ expand(FIREFOX).flat_map do |base|
206
+ ini = File.join(base, "profiles.ini")
207
+ File.exist?(ini) ? profiles(ini, base) : []
208
+ end
209
+ end
210
+
211
+ def safari
212
+ path = Config::HOME + SAFARI
213
+ File.exist?(path) ? [path] : []
214
+ end
215
+
216
+ def expand(bases) = bases.map { |b| Config::HOME + b }.select { |b| Dir.exist?(b) }
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,396 @@
1
+ # frozen_string_literal: true
2
+
3
+ # everything behind `booker --install`: shell completion, the config file,
4
+ # finding browser bookmarks, and the opt-in safari permission walkthrough
5
+
6
+ require "fileutils"
7
+ require "open3"
8
+
9
+ require_relative "output"
10
+
11
+ using Booker::Colors
12
+
13
+ module Booker
14
+ class Installer
15
+ include Output
16
+
17
+ # shells we ship completion for, and where those scripts live
18
+ SHELLS = %w[zsh bash fish].freeze
19
+
20
+ # what ~/.zshrc needs for a script in ~/.zsh/completion to be found
21
+ ZSHRC_LINES = [
22
+ "fpath=(~/.zsh/completion $fpath)",
23
+ "autoload -Uz compinit && compinit"
24
+ ].freeze
25
+ COMPLETIONS_DIR = File.expand_path("../../completions", __dir__).freeze
26
+
27
+ # if any of these exist, bash-completion is installed and will autoload our
28
+ # script out of the XDG user directory
29
+ BASH_COMPLETION_MARKERS = [
30
+ "/usr/share/bash-completion/bash_completion",
31
+ "/etc/bash_completion",
32
+ "/usr/local/etc/profile.d/bash_completion.sh",
33
+ "/opt/homebrew/etc/profile.d/bash_completion.sh"
34
+ ].freeze
35
+
36
+ # 'all' expands to the full install list (including opt-in safari)
37
+ ALL = %w[completion config bookmarks safari].freeze
38
+
39
+ def install(args)
40
+ args.flat_map { |target| /^all$/i.match?(target) ? ALL : target }
41
+ .each { |target| install_one(target) }
42
+ end
43
+
44
+ def install_one(target)
45
+ if /comp/i.match?(target) # completion for every shell on this machine
46
+ install_completion
47
+ elsif (shell = SHELLS.find { |s| target.downcase.include?(s) })
48
+ install_completion_for(shell) # completion for one named shell
49
+ elsif /book/i.match?(target) # bookmarks installation
50
+ install_bookmarks
51
+ elsif /conf/i.match?(target) # default config file generation
52
+ install_config
53
+ elsif /safari/i.match?(target) # opt-in Safari FDA setup (macOS only)
54
+ install_safari
55
+ else # unknown argument passed into install
56
+ pexit "Failure: ".red + "unknown installation option (#{target})"
57
+ end
58
+ end
59
+
60
+ # install completion for every supported shell present on this machine, so a
61
+ # zsh user who also drops into bash gets both without running install twice
62
+ def install_completion
63
+ found, missing = SHELLS.partition { |shell| shell_present?(shell) }
64
+
65
+ if found.empty?
66
+ puts "Warning: ".yel + "no supported shell found (#{SHELLS.join(", ")})"
67
+ return
68
+ end
69
+
70
+ found.each { |shell| install_completion_for(shell) }
71
+
72
+ puts "Skip: ".yel + "#{missing.join(", ")} not installed" unless missing.empty?
73
+ end
74
+
75
+ # every shell in SHELLS has an install_completion_<shell>, so adding a fourth
76
+ # means adding the script, the method, and the SHELLS entry - nothing here
77
+ def install_completion_for(shell)
78
+ send("install_completion_#{shell}")
79
+ end
80
+
81
+ def install_completion_zsh
82
+ # check if zsh is even installed for this user - capture3 raises
83
+ # Errno::ENOENT when it is not, same as the backtick this replaces
84
+ begin
85
+ out, _err, _status = Open3.capture3("zsh", "-c", "echo $fpath")
86
+ fpath = out.split(" ")
87
+ rescue
88
+ pexit "Failure: ".red + "zsh is probably not installed, could not find $fpath"
89
+ end
90
+
91
+ # Try user-writable directories first, then system directories
92
+ user_home = home
93
+ writable_dirs = fpath.select do |fp|
94
+ fp.start_with?(user_home) && File.directory?(fp) && File.writable?(fp)
95
+ end
96
+
97
+ # If no user-writable directories, try to create one
98
+ if writable_dirs.empty?
99
+ user_completion_dir = File.join(user_home, ".zsh", "completion")
100
+ begin
101
+ # mkdir_p is happy either way, but saying "Created" every time a
102
+ # developer reinstalls is a small lie about what just happened
103
+ existed = File.directory?(user_completion_dir)
104
+ FileUtils.mkdir_p(user_completion_dir)
105
+ writable_dirs << user_completion_dir
106
+ puts "Created user completion directory: #{user_completion_dir}".yel unless existed
107
+
108
+ # Auto-configure .zshrc if it exists
109
+ zshrc = File.join(user_home, ".zshrc")
110
+ if File.exist?(zshrc)
111
+ # the marker is the directory, not the whole block: a zshrc that
112
+ # already puts it on $fpath is configured, however it spelled the
113
+ # compinit call next to it
114
+ append_once(zshrc, ZSHRC_LINES, marker: ".zsh/completion", label: "~/.zshrc") do
115
+ puts "Run: ".yel + "source ~/.zshrc".cyan + " to activate"
116
+ end
117
+ else
118
+ puts "Add this to your ~/.zshrc: ".yel + "fpath=(~/.zsh/completion $fpath)".cyan
119
+ end
120
+ rescue
121
+ # Couldn't create user dir, try system dirs as fallback
122
+ end
123
+ end
124
+
125
+ # Try writable directories first, then all directories as fallback
126
+ dirs_to_try = writable_dirs + fpath.reject { |fp| writable_dirs.include?(fp) }
127
+
128
+ success = false
129
+ dirs_to_try.each do |fp|
130
+ next unless File.directory?(fp)
131
+
132
+ begin
133
+ # nothing is autoloaded here on purpose: `zsh -c 'autoload -U
134
+ # _booker'` loaded it into a subshell that exited on the next line.
135
+ # clear_zsh_compdump below is what reaches a new shell
136
+ File.write(File.join(fp, "_booker"), completion_script("_booker"))
137
+ puts "Success: ".grn + "installed zsh autocompletion in #{fp}"
138
+ success = true
139
+ break
140
+ rescue
141
+ # Try next directory silently
142
+ end
143
+ end
144
+
145
+ if success
146
+ clear_zsh_compdump
147
+ else
148
+ puts "Warning: ".yel + "Could not install ZSH completion to any directory in $fpath"
149
+ puts "Try manually: ".grn + "mkdir -p ~/.zsh/completion && booker --install zsh"
150
+ end
151
+ end
152
+
153
+ # compinit caches what it found in $fpath, so a shell started against an old
154
+ # dump keeps describing the completion booker just replaced. dropping the
155
+ # cache - zsh rebuilds it on the next start - is how a new script reaches
156
+ # the next shell. nothing helps the shell you ran this from, which autoloaded
157
+ # _booker for the life of the session, hence the unfunction advice
158
+ def clear_zsh_compdump
159
+ base = ENV["ZDOTDIR"] || home
160
+ dumps = Dir.glob(File.join(base, ".zcompdump*"))
161
+ return if dumps.empty?
162
+
163
+ dumps.each do |dump|
164
+ File.delete(dump)
165
+ rescue
166
+ # a dump we cannot remove is not worth failing an install over
167
+ end
168
+
169
+ puts "Refreshed: ".grn + "zsh completion cache (rebuilds on next shell)"
170
+ end
171
+
172
+ # bash-completion (when installed) autoloads from the XDG user directory.
173
+ # Without it there is nothing doing the loading, so fall back to a plain
174
+ # directory plus a source line in ~/.bashrc.
175
+ def install_completion_bash
176
+ autoloaded = bash_completion_present?
177
+ dir = if autoloaded
178
+ File.join(xdg_data_home, "bash-completion", "completions")
179
+ else
180
+ File.join(home, ".bash_completion.d")
181
+ end
182
+
183
+ FileUtils.mkdir_p(dir)
184
+ File.write(File.join(dir, "booker"), completion_script("booker.bash"))
185
+ puts "Success: ".grn + "installed bash completion in #{dir}"
186
+
187
+ # with bash-completion installed there is nothing left to wire up
188
+ unless autoloaded
189
+ append_once(
190
+ File.join(home, ".bashrc"),
191
+ "[ -f ~/.bash_completion.d/booker ] && . ~/.bash_completion.d/booker"
192
+ )
193
+ end
194
+
195
+ puts "Run: ".yel + "source ~/.bashrc".cyan + " to activate"
196
+ rescue => e
197
+ puts "Warning: ".yel + "could not install bash completion (#{e.message})"
198
+ end
199
+
200
+ # fish autoloads anything in its completions directory, so there is no rc
201
+ # file to edit here
202
+ def install_completion_fish
203
+ dir = File.join(xdg_config_home, "fish", "completions")
204
+ FileUtils.mkdir_p(dir)
205
+ File.write(File.join(dir, "booker.fish"), completion_script("booker.fish"))
206
+ puts "Success: ".grn + "installed fish completion in #{dir}"
207
+ puts "Run: ".yel + "exec fish".cyan + " to activate"
208
+ rescue => e
209
+ puts "Warning: ".yel + "could not install fish completion (#{e.message})"
210
+ end
211
+
212
+ # read one of the shipped completion scripts (completions/ sits next to lib/,
213
+ # and is listed in the gemspec so it ships with the gem)
214
+ def completion_script(name)
215
+ File.read(File.join(COMPLETIONS_DIR, name))
216
+ rescue Errno::ENOENT
217
+ pexit "Failure: ".red + "completion script #{name} missing from #{COMPLETIONS_DIR}"
218
+ end
219
+
220
+ # Picker.which, not `sh -c "command -v"`: a fork per shell to answer a PATH
221
+ # question booker already knows how to answer in process
222
+ def shell_present?(shell) = !Picker.which(shell).nil?
223
+
224
+ def bash_completion_present?
225
+ BASH_COMPLETION_MARKERS.any? { |marker| File.exist?(marker) }
226
+ end
227
+
228
+ # append to an rc file once - install is expected to be re-runnable without
229
+ # stacking up duplicates. `marker` is what counts as "already there" when
230
+ # that is narrower than what gets written; `label` is the name shown
231
+ def append_once(rcfile, lines, marker: lines, label: rcfile)
232
+ if File.exist?(rcfile) && File.read(rcfile).include?(marker)
233
+ puts "#{label} already configured".grn
234
+ return
235
+ end
236
+
237
+ File.open(rcfile, "a") do |f|
238
+ f.puts "\n# Booker completion"
239
+ f.puts lines
240
+ end
241
+ puts "Added completion to #{label}".grn
242
+ yield if block_given?
243
+ end
244
+
245
+ def home
246
+ ENV["HOME"] || "/usr/local"
247
+ end
248
+
249
+ def xdg_data_home
250
+ ENV["XDG_DATA_HOME"] || File.join(home, ".local", "share")
251
+ end
252
+
253
+ def xdg_config_home
254
+ ENV["XDG_CONFIG_HOME"] || File.join(home, ".config")
255
+ end
256
+
257
+ # locate bookmarks files, show the user, write the choice to the config.
258
+ # the search is Sources.discover, the same code auto-detection falls back
259
+ # on, so this offers exactly what booker would find on its own
260
+ def install_bookmarks
261
+ puts "searching for browser bookmarks..."
262
+ begin
263
+ bms = Sources.discover
264
+
265
+ if bms.empty? # no bookmarks found
266
+ puts "Failure: ".red + "bookmarks file could not be found."
267
+ raise
268
+ elsif bms.length == 1
269
+ # Auto-select if only one source found
270
+ selected = bms.first
271
+ puts "Found bookmark source: #{bookmark_type_label(Bookmarks.source_for(selected))} #{selected}".yel
272
+ save_bookmarks(selected, "config file updated with your bookmarks")
273
+ else # have user select a file
274
+ puts "select bookmarks source: "
275
+
276
+ # Offer "ALL" as first option if multiple sources found
277
+ puts "0".grn + " - " + "[ALL SOURCES]".cyan + " (search across all browsers)"
278
+
279
+ bms.each_with_index do |path, i|
280
+ label = bookmark_type_label(Bookmarks.source_for(path), color: true)
281
+ puts (i + 1).to_s.grn + " - " + label + " " + path
282
+ end
283
+
284
+ input = gets
285
+ raise "No input provided" if input.nil?
286
+ selection = input.chomp.to_i
287
+
288
+ if selection == 0
289
+ # User selected "ALL" - save array of all paths
290
+ puts "Selected: ".yel + "All sources (#{bms.length} bookmark files)"
291
+ Config.default.write(:bookmarks, bms)
292
+ puts "Success: ".grn + "config file updated to search all bookmark sources"
293
+ else
294
+ save_bookmarks(bms[selection - 1], "config file updated with your bookmarks")
295
+ end
296
+ end
297
+ rescue => e
298
+ puts e.message
299
+ pexit "Failure: ".red + "could not add bookmarks to config file ~/.booker"
300
+ end
301
+ end
302
+
303
+ def save_bookmarks(selected, message)
304
+ puts "Selected: ".yel + selected
305
+ Config.default.write(:bookmarks, selected)
306
+ puts "Success: ".grn + message
307
+ end
308
+
309
+ # the label and its color come off the browser table, so a fourth browser
310
+ # is a row there rather than two more branches here
311
+ def bookmark_type_label(type, color: false)
312
+ browser = Bookmarks::BROWSERS[type]
313
+ return "[?]" if browser.nil?
314
+
315
+ color ? Colors.paint(browser[:label], browser[:color]) : browser[:label]
316
+ end
317
+
318
+ def install_config
319
+ Config.default.write
320
+ puts "Success: ".grn + "example config file written to ~/.booker"
321
+ rescue
322
+ pexit "Failure: ".red + "could not write example config file to ~/.booker"
323
+ end
324
+
325
+ # Opt-in Safari setup: walks through granting Full Disk Access so booker
326
+ # can read ~/Library/Safari/Bookmarks.plist. Not included in the default
327
+ # --install flow because it requires a TCC permission grant.
328
+ def install_safari
329
+ plist = File.join(home, "Library/Safari/Bookmarks.plist")
330
+ fda_url = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"
331
+
332
+ unless RUBY_PLATFORM.include?("darwin")
333
+ puts "Skip: ".yel + "Safari support is macOS-only."
334
+ return
335
+ end
336
+
337
+ unless File.exist?(plist)
338
+ puts "Skip: ".yel + "Safari bookmarks not found at #{plist}"
339
+ puts "Hint: ".grn + "launch Safari at least once, then re-run."
340
+ return
341
+ end
342
+
343
+ if safari_readable?(plist)
344
+ puts "PASS: ".grn + "Safari bookmarks are already readable."
345
+ return
346
+ end
347
+
348
+ puts "Safari stores bookmarks at:"
349
+ puts " #{plist}".cyan
350
+ puts
351
+ puts "That file is protected by macOS TCC. Grant Full Disk Access to one of:"
352
+ puts
353
+ puts " [A] ".cyan + "Your terminal app".yel + " (simplest; inherited by any tool)"
354
+ puts " [B] ".cyan + "/usr/bin/plutil only".yel + " (narrower scope)"
355
+ puts
356
+ print "Pick [A] or [B] (default A): "
357
+ $stdout.flush
358
+ choice = $stdin.gets.to_s.strip.upcase
359
+ choice = "A" if choice.empty?
360
+ pexit "Error: ".red + "invalid choice." unless %w[A B].include?(choice)
361
+
362
+ puts
363
+ puts "Opening the Full Disk Access pane..."
364
+ system("open", fda_url)
365
+ puts
366
+
367
+ puts "In the pane that just opened:"
368
+ if choice == "A"
369
+ puts " 1. Click ".yel + "+".cyan + ", add your terminal app from /Applications"
370
+ puts " 2. Toggle it ".yel + "on".cyan
371
+ puts " 3. ".yel + "Fully quit".cyan + " the terminal (Cmd+Q), reopen it,"
372
+ puts " and re-run ".yel + "booker --install safari".cyan + " to verify."
373
+ else
374
+ puts " 1. Click ".yel + "+".cyan
375
+ puts " 2. Press ".yel + "Cmd+Shift+G".cyan + " (opens 'Go to Folder')".yel
376
+ puts " 3. Type ".yel + "/usr/bin/plutil".cyan + " and press Return".yel
377
+ puts " 4. Click ".yel + "Open".cyan + ", then toggle it ".yel + "on".cyan
378
+ puts
379
+ print "Press Return once you've added plutil and toggled it on... "
380
+ $stdout.flush
381
+ $stdin.gets
382
+
383
+ if safari_readable?(plist)
384
+ puts "PASS: ".grn + "Safari bookmarks are now readable."
385
+ else
386
+ puts "FAIL: ".red + "still can't read the bookmarks file."
387
+ puts "Try fully quitting the terminal (Cmd+Q) and re-running."
388
+ end
389
+ end
390
+ end
391
+
392
+ def safari_readable?(plist)
393
+ system("plutil", "-lint", "-s", plist, out: File::NULL, err: File::NULL)
394
+ end
395
+ end
396
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ # terminal width, colored strings, and exiting with a message: the presentation
4
+ # helpers the rest of booker leans on
5
+
6
+ require "io/console"
7
+
8
+ module Booker
9
+ # int number of columns on screen, answered in process rather than by a `tput
10
+ # cols` subshell on every run. io/console reads /dev/tty rather than stdout,
11
+ # so a width still comes back through a pipe, and nil when there is no
12
+ # terminal at all - a completion subshell, or ci
13
+ module Term
14
+ def self.width
15
+ @width ||= IO.console&.winsize&.last&.nonzero? ||
16
+ ENV["COLUMNS"]&.to_i&.nonzero? ||
17
+ 100
18
+ end
19
+ end
20
+
21
+ # compl. color codes space
22
+ CODEWIDTH = 16
23
+
24
+ # print a message and stop, for the cli and the installer both reporting a
25
+ # failure and exiting in one breath. stderr, not stdout: stdout is the data
26
+ # channel the completion scripts read
27
+ module Output
28
+ def pexit(msg, sig = 1)
29
+ warn msg
30
+ exit sig
31
+ end
32
+ end
33
+
34
+ # "Success: ".grn at every call site, but as a refinement rather than an open
35
+ # class: a gem claiming String#red for the whole process is taking a name it
36
+ # does not own. files that want these say `using Booker::Colors` at the top
37
+ module Colors
38
+ CODES = {red: 31, grn: 32, yel: 33, blu: 34, cyan: 36}.freeze
39
+
40
+ class << self
41
+ # nil means decide per call from the environment; true or false forces it,
42
+ # which is what the specs do since their $stdout is never a terminal
43
+ attr_writer :enabled
44
+
45
+ # honor NO_COLOR (no-color.org), and skip the escapes when stdout is not a
46
+ # terminal - otherwise `booker > out.txt` and `booker | less` fill up with
47
+ # raw \033[ sequences
48
+ def enabled?
49
+ return @enabled unless @enabled.nil?
50
+
51
+ ENV["NO_COLOR"].to_s.empty? && $stdout.tty?
52
+ end
53
+
54
+ # color by name, reachable without the refinement. a caller holding the
55
+ # color as data - the browser table's :yel, say - cannot go through the
56
+ # refined methods, because refinements are invisible to send
57
+ def paint(str, name)
58
+ return str unless enabled?
59
+
60
+ "\033[0;#{CODES.fetch(name)};49m#{str}\033[0;0m"
61
+ end
62
+ end
63
+
64
+ refine String do
65
+ def window(width)
66
+ if length >= width
67
+ self[0..width - 1]
68
+ else
69
+ ljust(width)
70
+ end
71
+ end
72
+
73
+ def blu = Colors.paint(self, :blu)
74
+
75
+ def cyan = Colors.paint(self, :cyan)
76
+
77
+ def yel = Colors.paint(self, :yel)
78
+
79
+ def grn = Colors.paint(self, :grn)
80
+
81
+ def red = Colors.paint(self, :red)
82
+ end
83
+ end
84
+ end