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.
@@ -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