xolo-admin 1.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +177 -0
- data/README.md +5 -0
- data/bin/xadm +114 -0
- data/lib/xolo/admin/command_line.rb +432 -0
- data/lib/xolo/admin/configuration.rb +196 -0
- data/lib/xolo/admin/connection.rb +191 -0
- data/lib/xolo/admin/cookie_jar.rb +81 -0
- data/lib/xolo/admin/credentials.rb +212 -0
- data/lib/xolo/admin/highline_terminal.rb +81 -0
- data/lib/xolo/admin/interactive.rb +762 -0
- data/lib/xolo/admin/jamf_pro.rb +75 -0
- data/lib/xolo/admin/options.rb +1139 -0
- data/lib/xolo/admin/processing.rb +1329 -0
- data/lib/xolo/admin/progress_history.rb +117 -0
- data/lib/xolo/admin/title.rb +285 -0
- data/lib/xolo/admin/title_editor.rb +57 -0
- data/lib/xolo/admin/validate.rb +1032 -0
- data/lib/xolo/admin/version.rb +221 -0
- data/lib/xolo/admin.rb +139 -0
- data/lib/xolo-admin.rb +8 -0
- metadata +139 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
# Copyright 2025 Pixar
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the terms set forth in the LICENSE.txt file available at
|
|
4
|
+
# at the root of this project.
|
|
5
|
+
#
|
|
6
|
+
|
|
7
|
+
# frozen_string_literal: true
|
|
8
|
+
|
|
9
|
+
module Xolo
|
|
10
|
+
|
|
11
|
+
module Admin
|
|
12
|
+
|
|
13
|
+
# Module for gathering and validating xadm options from an interactive terminal session
|
|
14
|
+
module Interactive
|
|
15
|
+
|
|
16
|
+
# Constants
|
|
17
|
+
###########################
|
|
18
|
+
###########################
|
|
19
|
+
|
|
20
|
+
MULTILINE_EDITORS = {
|
|
21
|
+
'vim (vi)' => '/usr/bin/vim',
|
|
22
|
+
'mg (emacs)' => '/usr/bin/mg',
|
|
23
|
+
'pico (nano)' => '/usr/bin/pico'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
MULTILINE_HEADER_SEPARATOR = "\nDO NOT EDIT anything above the next line:\n=================================="
|
|
27
|
+
|
|
28
|
+
DEFAULT_HIGHLINE_READLINE_PROMPT = 'Enter value'
|
|
29
|
+
|
|
30
|
+
HIGHLINE_READLINE_GATHER_ERR_INSTRUCTIONS = <<~ENDINSTR
|
|
31
|
+
Use tab for auto-completion, tab twice to see available choices
|
|
32
|
+
Type '#{Xolo::X}' to exit."
|
|
33
|
+
ENDINSTR
|
|
34
|
+
|
|
35
|
+
# Module methods
|
|
36
|
+
##############################
|
|
37
|
+
##############################
|
|
38
|
+
|
|
39
|
+
# when this module is included
|
|
40
|
+
def self.included(includer)
|
|
41
|
+
Xolo.verbose_include includer, self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Instance Methods
|
|
45
|
+
##########################
|
|
46
|
+
##########################
|
|
47
|
+
|
|
48
|
+
# @return [Highline] Our HighLine instance.
|
|
49
|
+
# Word wrap at terminal-width minus 5
|
|
50
|
+
##############################
|
|
51
|
+
def highline_cli
|
|
52
|
+
return @highline_cli if @highline_cli
|
|
53
|
+
|
|
54
|
+
@highline_cli ||= HighLine.new
|
|
55
|
+
@highline_cli.wrap_at = terminal_word_wrap if STDOUT.tty?
|
|
56
|
+
@highline_cli
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Use an interactive walkthru session to populate
|
|
60
|
+
# Xolo::Admin::Options.walkthru_cmd_opts
|
|
61
|
+
# @return [void]
|
|
62
|
+
###############################
|
|
63
|
+
def do_walkthru
|
|
64
|
+
return unless walkthru?
|
|
65
|
+
|
|
66
|
+
# only one readline thing per line
|
|
67
|
+
Readline.completion_append_character = nil
|
|
68
|
+
# all chars are allowed in readline choices
|
|
69
|
+
Readline.basic_word_break_characters = "\n"
|
|
70
|
+
|
|
71
|
+
# if the command doesn't take any options, there's nothing to walk through
|
|
72
|
+
return if Xolo::Admin::Options::COMMANDS[cli_cmd.command][:opts].empty?
|
|
73
|
+
|
|
74
|
+
display_walkthru_menu
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Build and display the walkthru menu for the given command
|
|
78
|
+
# @return [void]
|
|
79
|
+
##############################
|
|
80
|
+
def display_walkthru_menu
|
|
81
|
+
cmd = cli_cmd.command
|
|
82
|
+
done_with_menu = false
|
|
83
|
+
|
|
84
|
+
# we start off with our walkthru_cmd_opts being the same
|
|
85
|
+
# the same as current_opt_values
|
|
86
|
+
current_opt_values.to_h.each { |k, v| walkthru_cmd_opts[k] = v }
|
|
87
|
+
|
|
88
|
+
until done_with_menu
|
|
89
|
+
# clear the screen and show the menu header
|
|
90
|
+
display_walkthru_header
|
|
91
|
+
|
|
92
|
+
# Generate the menu items
|
|
93
|
+
highline_cli.choose do |menu|
|
|
94
|
+
menu.select_by = :index
|
|
95
|
+
|
|
96
|
+
menu.responses[:ambiguous_completion] = nil
|
|
97
|
+
menu.responses[:no_completion] = 'Unknown Choice'
|
|
98
|
+
|
|
99
|
+
# The menu items for setting values
|
|
100
|
+
cmd_details(cmd)[:opts].each do |key, deets|
|
|
101
|
+
curr_val = current_opt_values[key]
|
|
102
|
+
new_val = walkthru_cmd_opts[key]
|
|
103
|
+
not_avail = send(deets[:walkthru_na]) if deets[:walkthru_na]
|
|
104
|
+
menu_item = menu_item_text(deets[:label], oldval: curr_val, newval: new_val, not_avail: not_avail)
|
|
105
|
+
|
|
106
|
+
# no processing if item not available
|
|
107
|
+
if not_avail
|
|
108
|
+
# menu.choice(nil, nil, menu_item) {}
|
|
109
|
+
menu.choice(menu_item) {}
|
|
110
|
+
else
|
|
111
|
+
# menu.choice(nil, nil, menu_item) { prompt_for_walkthru_value key, deets, curr_val }
|
|
112
|
+
menu.choice(menu_item) { prompt_for_walkthru_value key, deets, curr_val }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# always show 'Cancel' in the same position
|
|
117
|
+
menu.choice(Xolo::CANCEL) do
|
|
118
|
+
done_with_menu = true
|
|
119
|
+
@walkthru_cancelled = true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# check for any required values missing or if
|
|
123
|
+
# there's internal inconsistency between given values
|
|
124
|
+
still_needed = missing_values
|
|
125
|
+
consistency_error = internal_consistency_error
|
|
126
|
+
|
|
127
|
+
# only show 'done' when all required values are there and
|
|
128
|
+
# consistency is OK
|
|
129
|
+
menu.choice(nil, nil, 'Done') { done_with_menu = true } if still_needed.empty? && consistency_error.nil?
|
|
130
|
+
|
|
131
|
+
# The prompt will include info about required values and consistency
|
|
132
|
+
prompt = Xolo::BLANK
|
|
133
|
+
prompt = "#{prompt}\n- Missing: #{still_needed.join Xolo::COMMA_JOIN}" unless still_needed.empty?
|
|
134
|
+
prompt = "#{prompt}\n- #{consistency_error}" if consistency_error
|
|
135
|
+
prompt = "#{prompt}\nYour Choice: "
|
|
136
|
+
menu.prompt = prompt
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
end # until done with menu
|
|
140
|
+
end # def self.display_title_menu(_title)
|
|
141
|
+
|
|
142
|
+
# @return [String, nil] If a string, a reason why the given menu item is not available now.
|
|
143
|
+
# If nil, the menu item is displayed normally.
|
|
144
|
+
##############################
|
|
145
|
+
def version_script_na
|
|
146
|
+
return unless walkthru_cmd_opts[:app_name] || walkthru_cmd_opts[:app_bundle_id]
|
|
147
|
+
|
|
148
|
+
'N/A when using App Name/BundleID'
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @return [String, nil] If a string, a reason why the given menu item is not available now.
|
|
152
|
+
# If nil, the menu item is displayed normally.
|
|
153
|
+
##############################
|
|
154
|
+
def app_name_bundleid_na
|
|
155
|
+
return unless walkthru_cmd_opts[:version_script]
|
|
156
|
+
|
|
157
|
+
'N/A when using Version Script'
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @return [String, nil] If a string, a reason why the given menu item is not available now.
|
|
161
|
+
# If nil, the menu item is displayed normally.
|
|
162
|
+
##############################
|
|
163
|
+
def ssvc_na
|
|
164
|
+
tgt_all = walkthru_cmd_opts[:release_groups]&.include?(Xolo::TARGET_ALL)
|
|
165
|
+
|
|
166
|
+
"N/A if Target Group is '#{Xolo::TARGET_ALL}'" if tgt_all
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @return [String, nil] If a string, a reason why the given menu item is not available now.
|
|
170
|
+
# If nil, the menu item is displayed normally.
|
|
171
|
+
##############################
|
|
172
|
+
def uninstall_script_na
|
|
173
|
+
'N/A if using Uninstall IDs' unless walkthru_cmd_opts[:uninstall_ids].pix_empty?
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# @return [String, nil] If a string, a reason why the given menu item is not available now.
|
|
177
|
+
# If nil, the menu item is displayed normally.
|
|
178
|
+
##############################
|
|
179
|
+
def uninstall_ids_na
|
|
180
|
+
'N/A if using Uninstall Script' unless walkthru_cmd_opts[:uninstall_script].pix_empty?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# @return [String, nil] If a string, a reason why the given menu item is not available now.
|
|
184
|
+
# If nil, the menu item is displayed normally.
|
|
185
|
+
##############################
|
|
186
|
+
def expiration_na
|
|
187
|
+
no_uninstall_script = walkthru_cmd_opts[:uninstall_script].pix_empty?
|
|
188
|
+
no_uninstall_ids = walkthru_cmd_opts[:uninstall_ids].pix_empty?
|
|
189
|
+
'N/A until either uninstall_script or uninstall-ids are set' if no_uninstall_script && no_uninstall_ids
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @return [String, nil] If a string, a reason why the given menu item is not available now.
|
|
193
|
+
# If nil, the menu item is displayed normally.
|
|
194
|
+
##############################
|
|
195
|
+
def expiration_paths_na
|
|
196
|
+
'N/A unless expiration is > 0' unless walkthru_cmd_opts[:expiration].to_i.positive?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @return [String, nil] If a string, a reason why the given menu item is not available now.
|
|
200
|
+
# If nil, the menu item is displayed normally.
|
|
201
|
+
##############################
|
|
202
|
+
def pw_na
|
|
203
|
+
admin_empty = walkthru_cmd_opts[:admin].pix_empty?
|
|
204
|
+
host_empty = walkthru_cmd_opts[:hostname].pix_empty?
|
|
205
|
+
'N/A until hostname and admin name are set' if host_empty || admin_empty
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# @return [String, nil] any current internal consistency error. will be nil when none remain
|
|
209
|
+
##############################
|
|
210
|
+
def internal_consistency_error
|
|
211
|
+
validate_internal_consistency walkthru_cmd_opts
|
|
212
|
+
nil
|
|
213
|
+
rescue Xolo::InvalidDataError => e
|
|
214
|
+
e.to_s
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Clear the terminal window and display the menu header above the highline menu
|
|
218
|
+
# @return [void]
|
|
219
|
+
##############################
|
|
220
|
+
def display_walkthru_header
|
|
221
|
+
header_text = Xolo::Admin::Options::COMMANDS[cli_cmd.command][:walkthru_header].dup
|
|
222
|
+
return unless header_text
|
|
223
|
+
|
|
224
|
+
header_text.sub! Xolo::Admin::Options::TARGET_TITLE_PLACEHOLDER, cli_cmd.title if cli_cmd.title
|
|
225
|
+
header_text.sub! Xolo::Admin::Options::TARGET_VERSION_PLACEHOLDER, cli_cmd.version if cli_cmd.version
|
|
226
|
+
|
|
227
|
+
header_sep_line = Xolo::DASH * header_text.length
|
|
228
|
+
|
|
229
|
+
system 'clear'
|
|
230
|
+
puts <<~ENDPUTS
|
|
231
|
+
#{header_sep_line}
|
|
232
|
+
#{header_text}
|
|
233
|
+
#{header_sep_line}
|
|
234
|
+
Current Settings => New Settings
|
|
235
|
+
|
|
236
|
+
ENDPUTS
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# @param lbl [String] the label to use at the start of the text
|
|
240
|
+
# e.g. 'Description'
|
|
241
|
+
# @param oldval [Object] the original value before we started doing
|
|
242
|
+
# whatever we are doing
|
|
243
|
+
# @param newval [Object] the latest value that was entered by the user
|
|
244
|
+
# @param not_avail [String] if the menu item is unavailable, this
|
|
245
|
+
# expalins why.
|
|
246
|
+
# @return [String] the menu item text
|
|
247
|
+
##################################
|
|
248
|
+
def menu_item_text(lbl, oldval: nil, newval: nil, not_avail: nil)
|
|
249
|
+
oldval = oldval.join(Xolo::COMMA_JOIN) if oldval.is_a? Array
|
|
250
|
+
newval = newval.join(Xolo::COMMA_JOIN) if newval.is_a? Array
|
|
251
|
+
|
|
252
|
+
txt = "#{lbl}:"
|
|
253
|
+
return "#{txt} ** #{not_avail}" if not_avail
|
|
254
|
+
|
|
255
|
+
txt = "#{lbl}: #{oldval}"
|
|
256
|
+
txt = "#{lbl}:\n#{oldval}\n" if txt.length >= terminal_word_wrap
|
|
257
|
+
return txt if oldval == newval
|
|
258
|
+
|
|
259
|
+
txt = "#{lbl}: #{oldval} -> #{newval}"
|
|
260
|
+
txt = "#{lbl}:\n#{oldval}\n ->\n#{newval}\n" if txt.length >= terminal_word_wrap
|
|
261
|
+
txt
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# prompt for an option value and store it in walkthru_cmd_opts
|
|
265
|
+
#
|
|
266
|
+
# @param key [Symbol] One of the keys of the opts hash for the current command;
|
|
267
|
+
# the value for which we are prompting
|
|
268
|
+
# @param deets [Hash] the details about the option key we prompting for
|
|
269
|
+
# @param curr_val [Object] The current value of the option, if any
|
|
270
|
+
#
|
|
271
|
+
# @return [void]
|
|
272
|
+
##############################
|
|
273
|
+
def prompt_for_walkthru_value(key, deets, _curr_val)
|
|
274
|
+
# current_value = default_for_value(key, deets, curr_val) # Needed??
|
|
275
|
+
current_value = walkthru_cmd_opts[key]
|
|
276
|
+
q_desc = question_desc(deets)
|
|
277
|
+
question = question_for_value(deets)
|
|
278
|
+
|
|
279
|
+
# Highline wants a separate lambda for conversion
|
|
280
|
+
# and validation, validation just returns boolean,
|
|
281
|
+
# but conversion returns the converted value.
|
|
282
|
+
# but our validation methods do the conversion.
|
|
283
|
+
#
|
|
284
|
+
# so we'll just return the last_converted_value we got
|
|
285
|
+
# when we validate, or nil if we don't validate
|
|
286
|
+
#
|
|
287
|
+
validate = validation_lambda(key, deets)
|
|
288
|
+
convert = validate ? ->(_ans) { last_converted_value } : ->(ans) { ans }
|
|
289
|
+
|
|
290
|
+
answer =
|
|
291
|
+
if deets[:multiline]
|
|
292
|
+
prompt_via_multiline_editor(
|
|
293
|
+
question: question,
|
|
294
|
+
q_desc: q_desc,
|
|
295
|
+
current_value: current_value,
|
|
296
|
+
validate: validate
|
|
297
|
+
)
|
|
298
|
+
elsif deets[:readline] == :get_files
|
|
299
|
+
prompt_for_local_files_via_readline(
|
|
300
|
+
question: question,
|
|
301
|
+
q_desc: q_desc,
|
|
302
|
+
deets: deets,
|
|
303
|
+
validate: validate
|
|
304
|
+
)
|
|
305
|
+
elsif deets[:multi]
|
|
306
|
+
prompt_for_multi_values_with_highline(
|
|
307
|
+
question: question,
|
|
308
|
+
q_desc: q_desc,
|
|
309
|
+
deets: deets,
|
|
310
|
+
convert: convert,
|
|
311
|
+
validate: validate
|
|
312
|
+
)
|
|
313
|
+
else
|
|
314
|
+
prompt_for_single_value_with_highline(
|
|
315
|
+
question: question,
|
|
316
|
+
q_desc: q_desc,
|
|
317
|
+
convert: convert,
|
|
318
|
+
validate: validate,
|
|
319
|
+
deets: deets
|
|
320
|
+
)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# answer = answer.map(&:strip).join("\n") if deets[:multiline]
|
|
324
|
+
# x means keep the current value
|
|
325
|
+
# answer = nil if answer == 'x'
|
|
326
|
+
|
|
327
|
+
# if no answer, keep the current value
|
|
328
|
+
return if answer.pix_empty?
|
|
329
|
+
|
|
330
|
+
# if 'none', erase the value in walkthru_cmd_opts
|
|
331
|
+
answer = nil if answer == Xolo::NONE
|
|
332
|
+
|
|
333
|
+
walkthru_cmd_opts[key] = answer
|
|
334
|
+
end # prompt for value
|
|
335
|
+
|
|
336
|
+
# Prompt for a one-line single value via highline, possibly with
|
|
337
|
+
# readline auto-completion from an array of possible values
|
|
338
|
+
#
|
|
339
|
+
# @param question [String] The question to ask
|
|
340
|
+
# @param q_desc [String] A longer description of what we're asking for
|
|
341
|
+
# @param convert [Lambda] The lambda for converting the validated value
|
|
342
|
+
# @param validate [Lambda] The lambda for validating the answer before conversion
|
|
343
|
+
# @param deets [Hash] The option-details for the value for which we are prompting
|
|
344
|
+
#
|
|
345
|
+
# @return [Object] The validated and converted value given by the user.
|
|
346
|
+
###############################
|
|
347
|
+
def prompt_for_single_value_with_highline(question:, q_desc:, convert:, validate:, deets:)
|
|
348
|
+
use_readline, convert, validate = setup_for_readline_in_highline(deets, convert, validate)
|
|
349
|
+
|
|
350
|
+
highline_cli.say q_desc
|
|
351
|
+
|
|
352
|
+
highline_cli.ask(question, convert) do |q|
|
|
353
|
+
q.readline = use_readline
|
|
354
|
+
q.echo = '*' if deets[:secure_interactive_input]
|
|
355
|
+
|
|
356
|
+
if validate
|
|
357
|
+
q.validate = validate
|
|
358
|
+
q.responses[:not_valid] = ->(_x) { "\nERROR: #{last_validation_error}" }
|
|
359
|
+
q.responses[:ask_on_error] = :question
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Prompt for an array of values using highline 'ask' with 'gather'
|
|
365
|
+
# and possibly readline auto-completion from an array
|
|
366
|
+
#
|
|
367
|
+
# @param question [String] The question to ask
|
|
368
|
+
# @param q_desc [String] A longer description of what we're asking for
|
|
369
|
+
# @param convert [Lambda] The lambda for converting the validated value
|
|
370
|
+
# @param validate [Lambda] The lambda for validating the answer before conversion
|
|
371
|
+
# @param deets [Hash] The option-details for the value for which we are prompting
|
|
372
|
+
#
|
|
373
|
+
# @return [Array, String] The validated and converted values given by the user, or 'none'
|
|
374
|
+
###############################
|
|
375
|
+
def prompt_for_multi_values_with_highline(question:, q_desc:, deets:, convert: nil, validate: nil)
|
|
376
|
+
use_readline, convert, validate = setup_for_readline_in_highline(deets, convert, validate)
|
|
377
|
+
|
|
378
|
+
highline_cli.say q_desc
|
|
379
|
+
|
|
380
|
+
chosen_values = highline_cli.ask(question, convert) do |q|
|
|
381
|
+
if use_readline
|
|
382
|
+
q.readline = true
|
|
383
|
+
q.responses[:no_completion] = "Unknown Choice.#{HIGHLINE_READLINE_GATHER_ERR_INSTRUCTIONS}"
|
|
384
|
+
q.responses[:ambiguous_completion] = "Ambiguous Choice.#{HIGHLINE_READLINE_GATHER_ERR_INSTRUCTIONS}"
|
|
385
|
+
end
|
|
386
|
+
if validate
|
|
387
|
+
q.validate = validate
|
|
388
|
+
q.responses[:not_valid] = ->(_x) { "\nERROR: #{last_validation_error}" }
|
|
389
|
+
q.responses[:ask_on_error] = :question
|
|
390
|
+
end
|
|
391
|
+
q.gather = Xolo::X
|
|
392
|
+
end
|
|
393
|
+
chosen_values.flatten!
|
|
394
|
+
|
|
395
|
+
# don't return an empty array if none was chosen, but
|
|
396
|
+
# return 'none' so that the whole value is cleared.
|
|
397
|
+
chosen_values = Xolo::NONE if chosen_values.include? Xolo::NONE
|
|
398
|
+
chosen_values
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Prompt for a single multiline value via an editor, like vim.
|
|
402
|
+
# This always returns a string.
|
|
403
|
+
# We handle validation ourselves, since we can't use highline.ask
|
|
404
|
+
#
|
|
405
|
+
# @param question [String] The question to ask
|
|
406
|
+
# @param q_desc [String] A longer description of what we're asking for
|
|
407
|
+
# @param current_value [String] The string to start editing.
|
|
408
|
+
# @param validate [Lambda] The lambda for validating the answer before conversion
|
|
409
|
+
# @return [String] the edited value.
|
|
410
|
+
##############################
|
|
411
|
+
def prompt_via_multiline_editor(question:, q_desc:, current_value: Xolo::BLANK, validate: nil)
|
|
412
|
+
highline_cli.say "#{question}\n#{q_desc}"
|
|
413
|
+
|
|
414
|
+
new_val = nil
|
|
415
|
+
validated_new_val = nil
|
|
416
|
+
editor = multiline_editor_to_use
|
|
417
|
+
return if editor == Xolo::CANCEL
|
|
418
|
+
|
|
419
|
+
until validated_new_val
|
|
420
|
+
new_val = edited_multiline_value editor, q_desc, current_value
|
|
421
|
+
if validate
|
|
422
|
+
if validate.call new_val
|
|
423
|
+
validated_new_val = last_converted_value
|
|
424
|
+
else
|
|
425
|
+
again = highline_cli.ask("\n#{last_validation_error}\nType a return to edit again, '#{Xolo::X}' to exit")
|
|
426
|
+
break if again == Xolo::X
|
|
427
|
+
end
|
|
428
|
+
else
|
|
429
|
+
validated_new_val = new_val
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
validated_new_val || current_value
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Highline's ability to do autocompletion for local file selection is limited at best
|
|
437
|
+
# (it only will autocomplete within a single directory, defaulting to the one
|
|
438
|
+
# containing the executable)
|
|
439
|
+
#
|
|
440
|
+
# So if we want a shell-style autocompletion for selecting one or more files
|
|
441
|
+
# then we'll use readline directly, where its pretty simple to do.
|
|
442
|
+
#
|
|
443
|
+
# @param question [String] The question to ask
|
|
444
|
+
# @param q_desc [String] A longer description of what we're asking for
|
|
445
|
+
# @param deets [Hash] The option-details for the value for which we are prompting
|
|
446
|
+
# @param validate [Lambda] The lambda for validating the answer before conversion
|
|
447
|
+
#
|
|
448
|
+
# @return [Object] The validated and converted value given by the user.
|
|
449
|
+
#######################
|
|
450
|
+
def prompt_for_local_files_via_readline(question:, q_desc:, deets:, validate: nil)
|
|
451
|
+
prompt = setup_for_readline_local_files(deets)
|
|
452
|
+
|
|
453
|
+
highline_cli.say "#{q_desc}\n#{question}"
|
|
454
|
+
|
|
455
|
+
validated_new_val = deets[:multi] ? [] : nil
|
|
456
|
+
all_done = false
|
|
457
|
+
until all_done
|
|
458
|
+
latest_input = Readline.readline(prompt, true)
|
|
459
|
+
break if latest_input == Xolo::X
|
|
460
|
+
return Xolo::NONE if !deets[:required] && (latest_input == Xolo::NONE)
|
|
461
|
+
|
|
462
|
+
if validate
|
|
463
|
+
if validate.call latest_input
|
|
464
|
+
latest_input = last_converted_value
|
|
465
|
+
else
|
|
466
|
+
highline_cli.say "#{last_validation_error}\nType 'x' to exit"
|
|
467
|
+
next
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
# if we are here, the latest_input is valid
|
|
471
|
+
|
|
472
|
+
# We only validate individual items, but the validation
|
|
473
|
+
# method might return an array (which it does for CLI option validation
|
|
474
|
+
# for options that are stored in arrays - it validates them all at once)
|
|
475
|
+
# so deal with that here or we'll get nested arrays here.
|
|
476
|
+
latest_input = latest_input.first if latest_input.is_a?(Array)
|
|
477
|
+
|
|
478
|
+
if deets[:multi]
|
|
479
|
+
validated_new_val << latest_input
|
|
480
|
+
else
|
|
481
|
+
validated_new_val = latest_input
|
|
482
|
+
all_done = true
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
end # until all_done
|
|
486
|
+
|
|
487
|
+
validated_new_val
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# should we use readline, and if so
|
|
491
|
+
# should we use an array of values or not?
|
|
492
|
+
#
|
|
493
|
+
# @param convert [Lambda] The lambda for converting the validated value
|
|
494
|
+
# @param validate [Lambda] The lambda for validating the answer before conversion
|
|
495
|
+
# @param deets [Hash] The option-details for the value for which we are prompting
|
|
496
|
+
#
|
|
497
|
+
# @return [Array] Three items:
|
|
498
|
+
# - if we should use readline (boolean)
|
|
499
|
+
# - the new 'convert' value - either the original passed to us or an array of possible values
|
|
500
|
+
# - the new 'validate' value - either the original passed to us or nil if using an array of values
|
|
501
|
+
############################
|
|
502
|
+
def setup_for_readline_in_highline(deets, convert, validate)
|
|
503
|
+
# if deets[:readline] is a symbol, its an xadm method that returns an array
|
|
504
|
+
# of the possible values for readline completion and validation;
|
|
505
|
+
# only things in the array are allowed, so no need for other validation or conversion
|
|
506
|
+
# We add 'x' and 'none' to the list so they will be accepted for exiting and
|
|
507
|
+
# clearing.
|
|
508
|
+
#
|
|
509
|
+
# if its just truthy then we use readline without a pre-set list of values
|
|
510
|
+
# (e.g. paths, which might not exist locally) and may have a separate validate
|
|
511
|
+
# and convert lambdas
|
|
512
|
+
use_readline =
|
|
513
|
+
if deets[:readline]
|
|
514
|
+
if deets[:readline].is_a? Symbol
|
|
515
|
+
convert = send deets[:readline]
|
|
516
|
+
convert << Xolo::NONE unless deets[:required]
|
|
517
|
+
convert << Xolo::X
|
|
518
|
+
validate = nil
|
|
519
|
+
end
|
|
520
|
+
true
|
|
521
|
+
else
|
|
522
|
+
false
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
if use_readline
|
|
526
|
+
# Case Insensitivity, aka deets[:readline_casefold]
|
|
527
|
+
#
|
|
528
|
+
# If deets[:readline_casefold] is explicitly true or false, we honor that.
|
|
529
|
+
#
|
|
530
|
+
# Otherwise, when we have an array of possible values, we make readline case insensitive.
|
|
531
|
+
# and without such array, we make it sensitive (e.g. when selecting existing file paths)
|
|
532
|
+
#
|
|
533
|
+
# Setting this ENV is how we use our monkey patch to make this work
|
|
534
|
+
ENV['XADM_HIGHLINE_READLINE_CASE_INSENSITIVE'] =
|
|
535
|
+
case deets[:readline_casefold]
|
|
536
|
+
when true
|
|
537
|
+
Xolo::X
|
|
538
|
+
when false
|
|
539
|
+
nil
|
|
540
|
+
else
|
|
541
|
+
convert.is_a?(Array) ? Xolo::X : nil
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Setting this ENV is how we use our monkey patch to make this work
|
|
545
|
+
prompt = deets[:readline_prompt] || deets[:multi_prompt] || deets[:label] || DEFAULT_HIGHLINE_READLINE_PROMPT
|
|
546
|
+
ENV['XADM_HIGHLINE_READLINE_PROMPT'] = "#{prompt}: "
|
|
547
|
+
end # if use_readline
|
|
548
|
+
|
|
549
|
+
[use_readline, convert, validate]
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# set up readline for local file autocomplete
|
|
553
|
+
# return the prompt we'll be using with readline
|
|
554
|
+
#
|
|
555
|
+
# @param deets [Hash] The option-details for the value for which we are prompting
|
|
556
|
+
#
|
|
557
|
+
# @return [String] the prompt to use with readline
|
|
558
|
+
#######################
|
|
559
|
+
def setup_for_readline_local_files(deets)
|
|
560
|
+
Readline.completion_append_character = nil
|
|
561
|
+
Readline.basic_word_break_characters = "\n"
|
|
562
|
+
Readline.completion_proc = proc do |str|
|
|
563
|
+
str = Pathname.new(str).expand_path
|
|
564
|
+
str = str.directory? ? "#{str}/" : str.to_s
|
|
565
|
+
Dir[str + '*'].grep(/^#{Regexp.escape(str)}/)
|
|
566
|
+
end
|
|
567
|
+
prompt = deets[:readline_prompt] || deets[:label] || DEFAULT_HIGHLINE_READLINE_PROMPT
|
|
568
|
+
"#{prompt}: "
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# The 'default' value for the highline question
|
|
572
|
+
# when prompting for a value
|
|
573
|
+
# TODO: POSSIBLY NOT NEEDED. See commented-out call above
|
|
574
|
+
##############################
|
|
575
|
+
def default_for_value(key, deets, curr_val)
|
|
576
|
+
# default is the current value, or the
|
|
577
|
+
# defined value if no current.
|
|
578
|
+
default = walkthru_cmd_opts[key] || curr_val || deets[:default]
|
|
579
|
+
default = default.join(Xolo::COMMA_JOIN) if default.is_a? Array
|
|
580
|
+
default
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# The multi-lines of text describing the value above the prompt
|
|
584
|
+
# @param deets [Hash] The option-details for the value for which we are prompting
|
|
585
|
+
# @return [String] the text to display
|
|
586
|
+
##############################
|
|
587
|
+
def question_desc(deets)
|
|
588
|
+
q_desc = +"============= #{deets[:label]} =============\n"
|
|
589
|
+
q_desc << deets[:desc]
|
|
590
|
+
|
|
591
|
+
if deets[:multiline]
|
|
592
|
+
# nada, will be shown in the editor
|
|
593
|
+
elsif deets[:multi]
|
|
594
|
+
q_desc << "\nEnter one value per line."
|
|
595
|
+
q_desc << "\nUse tab for auto-completion, tab twice to see available choices" if deets[:readline]
|
|
596
|
+
q_desc << "\nType '#{Xolo::X}' on a line by itself to exit." if deets[:validate]
|
|
597
|
+
|
|
598
|
+
else
|
|
599
|
+
q_desc << "\nType a return to keep the current value."
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
q_desc
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# The line of text prompting for a value.
|
|
606
|
+
# End with a space to keep prompt on same line
|
|
607
|
+
#
|
|
608
|
+
# @param deets [Hash] The option-details for the value for which we are prompting
|
|
609
|
+
# @return [String] the one-line prompt to display
|
|
610
|
+
##############################
|
|
611
|
+
def question_for_value(deets)
|
|
612
|
+
question = +"Enter #{deets[:label]}"
|
|
613
|
+
|
|
614
|
+
if deets[:type] == :boolean
|
|
615
|
+
question << ', (y/n)'
|
|
616
|
+
elsif !deets[:required]
|
|
617
|
+
question << ", use '#{Xolo::NONE}' to unset"
|
|
618
|
+
end
|
|
619
|
+
question << ': ' unless deets[:readline]
|
|
620
|
+
question
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# Retun a lambda that calls one of our validation methods to validate
|
|
624
|
+
# a walkthru value.
|
|
625
|
+
#
|
|
626
|
+
# Highline requires validation lambdas to return a boolean, and uses
|
|
627
|
+
# a separate lambda for type conversion.
|
|
628
|
+
# Since our validation methods do both, this lambda will put the converted
|
|
629
|
+
# result into the 'last_converted_value' accessor, or capture the error,
|
|
630
|
+
# and then return a boolean.
|
|
631
|
+
#
|
|
632
|
+
# Later the lambda we give to highline for conversion will just return
|
|
633
|
+
# the last converted value, as stored in the last_converted_value accessor.
|
|
634
|
+
#
|
|
635
|
+
# @return [Lambda, nil] The lambda that highline will use to validate
|
|
636
|
+
# (and convert) a value, nil if we accept whatever was given.
|
|
637
|
+
#
|
|
638
|
+
##############################
|
|
639
|
+
def validation_lambda(key, deets)
|
|
640
|
+
val_meth = validation_method(key, deets)
|
|
641
|
+
return unless val_meth
|
|
642
|
+
|
|
643
|
+
# lambda to validate the value given.
|
|
644
|
+
# must return boolean for Highline to deal with it.
|
|
645
|
+
lambda do |ans|
|
|
646
|
+
# to start, the converted value is just the given value.
|
|
647
|
+
#
|
|
648
|
+
# Use self here, otherwise the lambda sees 'last_converted_value ='
|
|
649
|
+
# as a local variable assignment, not a setter method call
|
|
650
|
+
self.last_converted_value = ans
|
|
651
|
+
|
|
652
|
+
# default to the pre-written error message
|
|
653
|
+
self.last_validation_error = deets[:invalid_msg]
|
|
654
|
+
|
|
655
|
+
# if entering multi-values, a 'x' is how we get out of
|
|
656
|
+
# the loop
|
|
657
|
+
return true if deets[:multi] && ans == Xolo::X
|
|
658
|
+
|
|
659
|
+
# but for anything not multi, an empty response
|
|
660
|
+
# means user just hit return, nothing to validate,
|
|
661
|
+
# no changes to make
|
|
662
|
+
return true if ans.pix_empty?
|
|
663
|
+
|
|
664
|
+
# If this value isn't required, accept 'none'
|
|
665
|
+
# which clears the value
|
|
666
|
+
return true if !deets[:required] && (ans == Xolo::NONE)
|
|
667
|
+
|
|
668
|
+
# otherwise 'none' becomes nil and will be validated
|
|
669
|
+
# and will fail if a value is required
|
|
670
|
+
ans_to_validate = ans == Xolo::NONE ? nil : ans
|
|
671
|
+
|
|
672
|
+
# validate using the val_meth,
|
|
673
|
+
# saving the validated/converted value for use in the
|
|
674
|
+
# convert method.
|
|
675
|
+
self.last_converted_value = send(val_meth, ans_to_validate)
|
|
676
|
+
true
|
|
677
|
+
|
|
678
|
+
# if validation fails, set the last_validation_error
|
|
679
|
+
# so we can display it and ask again
|
|
680
|
+
rescue Xolo::InvalidDataError => e
|
|
681
|
+
self.last_validation_error = e.to_s
|
|
682
|
+
false
|
|
683
|
+
end # lambda
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# getter/setter for the value converted by the last validation
|
|
687
|
+
# method call - we do this so the same value is available in the
|
|
688
|
+
# convert and validate lambdas
|
|
689
|
+
#
|
|
690
|
+
# @return [Object]
|
|
691
|
+
##############################
|
|
692
|
+
attr_accessor :last_converted_value
|
|
693
|
+
|
|
694
|
+
# getter/setter for any validation error message when
|
|
695
|
+
# a validation fails.
|
|
696
|
+
# @return [String]
|
|
697
|
+
##############################
|
|
698
|
+
attr_accessor :last_validation_error
|
|
699
|
+
|
|
700
|
+
# The method used to validate and convert a value
|
|
701
|
+
# @param deets [Hash] The option-details for the value for which we are prompting
|
|
702
|
+
# @param key [Symbol] One of the keys of the opts hash for the current command;
|
|
703
|
+
# the value for which we are prompting
|
|
704
|
+
# @return [String, Symbol, nil] The method which will validate the value for the key
|
|
705
|
+
##############################
|
|
706
|
+
def validation_method(key, deets)
|
|
707
|
+
case deets[:validate]
|
|
708
|
+
when TrueClass then "validate_#{key}"
|
|
709
|
+
when Symbol then deets[:validate]
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# @return [Array<String>] The names of any required opts that have no current value.
|
|
714
|
+
# Displayed at the bottom of the walkthru menu.
|
|
715
|
+
##################################
|
|
716
|
+
def missing_values
|
|
717
|
+
missing_values = []
|
|
718
|
+
required_values.each do |key, deets|
|
|
719
|
+
next if walkthru_cmd_opts[key]
|
|
720
|
+
|
|
721
|
+
missing_values << deets[:label]
|
|
722
|
+
end
|
|
723
|
+
missing_values
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Prompt for an editor to use from those in MULTILINE_EDITORS
|
|
727
|
+
# @return [String] the path to an editor to use for multiline values.
|
|
728
|
+
##################
|
|
729
|
+
def multiline_editor_to_use
|
|
730
|
+
return config.editor if config.editor
|
|
731
|
+
|
|
732
|
+
highline_cli.choose do |menu|
|
|
733
|
+
menu.select_by = :index
|
|
734
|
+
menu.prompt = 'Choose an editor:'
|
|
735
|
+
MULTILINE_EDITORS.each do |name, cmd|
|
|
736
|
+
menu.choice(cmd, nil, name)
|
|
737
|
+
end # MULTILINE_EDITORS.each
|
|
738
|
+
menu.choice(Xolo::CANCEL)
|
|
739
|
+
end # @cli.choose
|
|
740
|
+
end # def
|
|
741
|
+
|
|
742
|
+
# Save some text in a temp file, edit it with the desired
|
|
743
|
+
# multiline editor, save it then return the edited value.
|
|
744
|
+
#
|
|
745
|
+
# @param editor [String, Pathname] The path to the editor to use
|
|
746
|
+
# @param text_to_edit [String] The text to edit
|
|
747
|
+
#
|
|
748
|
+
# @return [String] the edited text.
|
|
749
|
+
##################
|
|
750
|
+
def edited_multiline_value(editor, desc, text_to_edit)
|
|
751
|
+
f = Pathname.new(Tempfile.new('xadm-multiline'))
|
|
752
|
+
editor_content = "#{desc.chomp}\n#{MULTILINE_HEADER_SEPARATOR}\n#{text_to_edit}"
|
|
753
|
+
f.pix_save editor_content
|
|
754
|
+
system "#{editor} #{f}"
|
|
755
|
+
f.read.split(MULTILINE_HEADER_SEPARATOR).last
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
end # module Interactive
|
|
759
|
+
|
|
760
|
+
end # module Admin
|
|
761
|
+
|
|
762
|
+
end # module Xolo
|