treequel 1.0.0 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/bin/treequel.orig ADDED
@@ -0,0 +1,963 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+
5
+ require 'abbrev'
6
+ require 'columnize'
7
+ require 'digest/sha1'
8
+ require 'logger'
9
+ require 'open3'
10
+ require 'optparse'
11
+ require 'ostruct'
12
+ require 'pathname'
13
+ require 'readline'
14
+ require 'shellwords'
15
+ require 'tempfile'
16
+ require 'terminfo'
17
+ require 'termios'
18
+ require 'uri'
19
+
20
+ require 'treequel'
21
+ require 'treequel/mixins'
22
+ require 'treequel/constants'
23
+
24
+
25
+ ### Monkeypatch for resetting an OpenStruct's state.
26
+ class OpenStruct
27
+
28
+ ### Clear all defined fields and values.
29
+ def clear
30
+ @table.clear
31
+ end
32
+
33
+ end
34
+
35
+
36
+ # The Treequel shell.
37
+ class Treequel::Shell
38
+ include Readline,
39
+ Treequel::Loggable,
40
+ Treequel::Constants::Patterns
41
+
42
+ # Set some ANSI escape code constants (Shamelessly stolen from Perl's
43
+ # Term::ANSIColor by Russ Allbery <rra@stanford.edu> and Zenin <zenin@best.com>
44
+ ANSI_ATTRIBUTES = {
45
+ 'clear' => 0,
46
+ 'reset' => 0,
47
+ 'bold' => 1,
48
+ 'dark' => 2,
49
+ 'underline' => 4,
50
+ 'underscore' => 4,
51
+ 'blink' => 5,
52
+ 'reverse' => 7,
53
+ 'concealed' => 8,
54
+
55
+ 'black' => 30, 'on_black' => 40,
56
+ 'red' => 31, 'on_red' => 41,
57
+ 'green' => 32, 'on_green' => 42,
58
+ 'yellow' => 33, 'on_yellow' => 43,
59
+ 'blue' => 34, 'on_blue' => 44,
60
+ 'magenta' => 35, 'on_magenta' => 45,
61
+ 'cyan' => 36, 'on_cyan' => 46,
62
+ 'white' => 37, 'on_white' => 47
63
+ }
64
+
65
+
66
+ # Prompt text for #prompt_for_multiple_values
67
+ MULTILINE_PROMPT = <<-'EOF'
68
+ Enter one or more values for '%s'.
69
+ A blank line finishes input.
70
+ EOF
71
+
72
+ # Some ANSI codes for fancier stuff
73
+ CLEAR_TO_EOL = "\e[K"
74
+ CLEAR_CURRENT_LINE = "\e[2K"
75
+
76
+ # Log levels
77
+ LOG_LEVELS = {
78
+ 'debug' => Logger::DEBUG,
79
+ 'info' => Logger::INFO,
80
+ 'warn' => Logger::WARN,
81
+ 'error' => Logger::ERROR,
82
+ 'fatal' => Logger::FATAL,
83
+ }.freeze
84
+ LOG_LEVEL_NAMES = LOG_LEVELS.invert.freeze
85
+
86
+ # Command option parsers
87
+ OPTION_PARSERS = {}
88
+
89
+ # Path to the default history file
90
+ HISTORY_FILE = Pathname( "~/.treequel.history" )
91
+
92
+ # Number of items to store in history by default
93
+ DEFAULT_HISTORY_SIZE = 100
94
+
95
+
96
+ #################################################################
97
+ ### C L A S S M E T H O D S
98
+ #################################################################
99
+
100
+ ### Create an option parser from the specified +block+ for the given +command+ and register
101
+ ### it. Many thanks to apeiros and dominikh on #Ruby-Pro for the ideas behind this.
102
+ def self::set_options( command, &block )
103
+ options = OpenStruct.new
104
+ oparser = OptionParser.new( "Help for #{command}" ) do |o|
105
+ yield( o, options )
106
+ end
107
+ oparser.default_argv = []
108
+
109
+ OPTION_PARSERS[command.to_sym] = [oparser, options]
110
+ end
111
+
112
+
113
+ #################################################################
114
+ ### I N S T A N C E M E T H O D S
115
+ #################################################################
116
+
117
+ ### Create a new shell that will traverse the directory at the specified +uri+.
118
+ def initialize( uri )
119
+ Treequel.logger.level = Logger::WARN
120
+ Treequel::Branch.include_operational_attrs = true
121
+
122
+ @uri = uri
123
+ @quit = false
124
+ @dir = Treequel.directory( @uri )
125
+ @currbranch = @dir
126
+ @columns = TermInfo.screen_width
127
+ @rows = TermInfo.screen_height
128
+
129
+ @commands = self.find_commands
130
+ @completions = @commands.abbrev
131
+ @command_table = make_command_table( @commands )
132
+ end
133
+
134
+
135
+ ### The command loop: run the shell until the user wants to quit
136
+ def run
137
+ message "Connected to %s" % [ @uri ]
138
+
139
+ # Set up the completion callback
140
+ self.setup_completion
141
+
142
+ # Load saved command-line history
143
+ self.read_history
144
+
145
+ # Run until something sets the quit flag
146
+ until @quit
147
+ $stderr.puts
148
+ prompt = make_prompt_string( @currbranch.dn + '> ' )
149
+ Readline.basic_word_break_characters = ''
150
+ input = Readline.readline( prompt, true )
151
+ self.log.debug "Input is: %p" % [ input ]
152
+
153
+ # EOL makes the shell quit
154
+ if input.nil?
155
+ self.log.debug "EOL: setting quit flag"
156
+ @quit = true
157
+
158
+ # Blank input -- just reprompt
159
+ elsif input == ''
160
+ self.log.debug "No command. Re-displaying the prompt."
161
+
162
+ # Parse everything else into command + everything else
163
+ else
164
+ self.log.debug "Dispatching input: %p" % [ input ]
165
+ self.dispatch_cmd( input )
166
+ end
167
+ end
168
+
169
+ message "\nSaving history...\n"
170
+ self.save_history
171
+
172
+ message "done."
173
+ end
174
+
175
+
176
+ ### Parse the specified +input+ into a command, options, and arguments and dispatch them
177
+ ### to the appropriate command method.
178
+ def dispatch_cmd( input )
179
+ command, *args = Shellwords.shellwords( input )
180
+
181
+ # If it's a valid command, run it
182
+ if meth = @command_table[ command ]
183
+ full_command = @completions[ command ].to_sym
184
+
185
+ # If there's a registered optionparser for the command, use it to
186
+ # split out options and arguments, then pass those to the command.
187
+ if OPTION_PARSERS.key?( full_command )
188
+ oparser, options = OPTION_PARSERS[ full_command ]
189
+ self.log.debug "Got an option-parser for #{full_command}."
190
+
191
+ cmdargs = oparser.parse( args )
192
+ self.log.debug " options=%p, args=%p" % [ options, cmdargs ]
193
+ meth.call( options, *cmdargs )
194
+
195
+ options.clear
196
+
197
+ # ...otherwise just call it with all the args.
198
+ else
199
+ meth.call( *args )
200
+ end
201
+
202
+ # ...otherwise call the fallback handler
203
+ else
204
+ self.handle_missing_cmd( command )
205
+ end
206
+
207
+ rescue => err
208
+ error_message( err.class.name, err.message )
209
+ err.backtrace.each do |frame|
210
+ self.log.debug " " + frame
211
+ end
212
+ end
213
+
214
+
215
+ #########
216
+ protected
217
+ #########
218
+
219
+ ### Set up Readline completion
220
+ def setup_completion
221
+ Readline.completion_proc = self.method( :completion_callback ).to_proc
222
+ Readline.completer_word_break_characters = ''
223
+ end
224
+
225
+
226
+ ### Read command line history from HISTORY_FILE
227
+ def read_history
228
+ histfile = HISTORY_FILE.expand_path
229
+
230
+ if histfile.exist?
231
+ lines = histfile.readlines.collect {|line| line.chomp }
232
+ self.log.debug "Read %d saved history commands from %s." % [ lines.nitems, histfile ]
233
+ Readline::HISTORY.push( *lines )
234
+ else
235
+ self.log.debug "History file '%s' was empty or non-existant." % [ histfile ]
236
+ end
237
+ end
238
+
239
+
240
+ ### Save command line history to HISTORY_FILE
241
+ def save_history
242
+ histfile = HISTORY_FILE.expand_path
243
+
244
+ lines = Readline::HISTORY.to_a.reverse.uniq.reverse
245
+ lines = lines[ -DEFAULT_HISTORY_SIZE, DEFAULT_HISTORY_SIZE ] if
246
+ lines.nitems > DEFAULT_HISTORY_SIZE
247
+
248
+ self.log.debug "Saving %d history lines to %s." % [ lines.length, histfile ]
249
+
250
+ histfile.open( File::WRONLY|File::CREAT|File::TRUNC ) do |ofh|
251
+ ofh.puts( *lines )
252
+ end
253
+ end
254
+
255
+
256
+ ### Handle completion requests from Readline.
257
+ def completion_callback( input )
258
+ self.log.debug "Input completion: %p" % [ input ]
259
+ parts = Shellwords.shellwords( input )
260
+
261
+ # If there aren't any arguments, it's command completion
262
+ if parts.length == 1
263
+ # One completion means it's an unambiguous match, so just complete it.
264
+ possible_completions = @commands.grep( /^#{Regexp.quote(input)}/ ).sort
265
+ self.log.debug " possible completions: %p" % [ possible_completions ]
266
+ return possible_completions
267
+ else
268
+ incomplete = parts.pop
269
+ possible_completions = @currbranch.children.
270
+ collect {|br| br.rdn }.grep( /^#{Regexp.quote(incomplete)}/i ).sort
271
+
272
+ possible_completions.map! do |lastpart|
273
+ parts.join( ' ' ) + ' ' + lastpart
274
+ end
275
+ end
276
+ end
277
+
278
+
279
+ #################################################################
280
+ ### C O M M A N D S
281
+ #################################################################
282
+
283
+ ### Show the completions hash
284
+ def show_completions_command
285
+ message "Completions:", @completions.inspect
286
+ end
287
+
288
+
289
+ ### Show help text for the specified command, or a list of all available commands
290
+ ### if none is specified.
291
+ def help_command( *args )
292
+ if args.empty?
293
+ $stderr.puts
294
+ message colorize( "Available commands", :bold, :white ),
295
+ *columnize(@commands)
296
+ else
297
+ cmd = args.shift.to_sym
298
+ if OPTION_PARSERS.key?( cmd )
299
+ oparser, _ = OPTION_PARSERS[ cmd ]
300
+ self.log.debug "Setting summary width to: %p" % [ @columns ]
301
+ oparser.summary_width = @columns
302
+ output = oparser.to_s.sub( /^(.*?)\n/ ) do |match|
303
+ colorize( :bold, :white ) { match }
304
+ end
305
+
306
+ $stderr.puts
307
+ message( output )
308
+ else
309
+ error_message( "No help for '#{cmd}'" )
310
+ end
311
+ end
312
+ end
313
+
314
+
315
+ ### Quit the shell.
316
+ def quit_command( *args )
317
+ message "Okay, exiting."
318
+ @quit = true
319
+ end
320
+
321
+
322
+ ### Set the logging level (if invoked with an argument) or display the current
323
+ ### level (with no argument).
324
+ def log_command( *args )
325
+ newlevel = args.shift
326
+ if newlevel
327
+ if LOG_LEVELS.key?( newlevel )
328
+ Treequel.logger.level = LOG_LEVELS[ newlevel ]
329
+ message "Set log level to: %s" % [ newlevel ]
330
+ else
331
+ levelnames = LOG_LEVEL_NAMES.keys.sort.join(', ')
332
+ raise "Invalid log level %p: valid values are:\n %s" % [ newlevel, levelnames ]
333
+ end
334
+ else
335
+ message "Log level is currently: %s" %
336
+ [ LOG_LEVEL_NAMES[Treequel.logger.level] ]
337
+ end
338
+ end
339
+
340
+
341
+ ### Display LDIF for the specified RDNs.
342
+ def cat_command( *args )
343
+ args.each do |rdn|
344
+ branch = @currbranch.get_child( rdn )
345
+ message( format_ldif(branch.to_ldif) )
346
+ end
347
+ end
348
+
349
+
350
+ ### List the children of the branch specified by the given +rdn+, or the current branch if none
351
+ ### are specified.
352
+ def ls_command( options, *args )
353
+ targets = []
354
+
355
+ # No argument, just use the current branch
356
+ if args.empty?
357
+ targets << @currbranch
358
+
359
+ # Otherwise, list each one specified
360
+ else
361
+ args.each do |rdn|
362
+ if branch = @currbranch.get_child( rdn )
363
+ targets << branch
364
+ else
365
+ error_message( "cannot access #{rdn}: no such entry" )
366
+ end
367
+ end
368
+ end
369
+
370
+ # Fetch each branch's children, sort them, format them in columns, and highlight them
371
+ targets.each do |branch|
372
+ if options.longform
373
+ message self.make_longform_ls_output( branch, options )
374
+ else
375
+ message self.make_shortform_ls_output( branch, options )
376
+ end
377
+ end
378
+ end
379
+ set_options :ls do |oparser, options|
380
+ oparser.banner = "ls [OPTIONS] [DNs]"
381
+
382
+ oparser.on( "-l", "--long", FalseClass, "List in long format." ) do
383
+ options.longform = true
384
+ end
385
+
386
+ end
387
+
388
+
389
+ ### Generate long-form output lines for the 'ls' command for the given +branch+.
390
+ def make_longform_ls_output( branch, options )
391
+ rows = []
392
+ children = branch.children
393
+ rows << colorize( :underscore, :cyan ) { "total %d" % [children.length] }
394
+
395
+ # Calcuate column widths
396
+ oclen = children.map do |branch|
397
+ branch.include_operational_attrs = true
398
+ branch[:structuralObjectClass].length
399
+ end.max
400
+ moddnlen = children.map do |branch|
401
+ branch[:modifiersName].length
402
+ end.max
403
+
404
+ children.
405
+ sort_by {|branch| branch.rdn.downcase }.
406
+ each do |branch|
407
+ # -rw-r--r-- 2 mgranger staff 979 2009-07-27 11:55 Rakefile.local
408
+ #
409
+ # modifiersName: cn=admin,dc=laika,dc=com
410
+ # hasSubordinates: TRUE
411
+ # modifyTimestamp: 20090520232650Z
412
+ # structuralObjectClass: organizationalUnit
413
+ rows << "%#{oclen}s %#{moddnlen}s %s %s%s" % [
414
+ branch[:structuralObjectClass],
415
+ branch[:modifiersName],
416
+ branch[:modifyTimestamp].strftime('%Y-%m-%d %H:%M'),
417
+ format_rdn( branch.rdn ),
418
+ branch[:hasSubordinates] ? '/' : ''
419
+ ]
420
+ end
421
+
422
+ return rows
423
+ end
424
+
425
+
426
+ ### Generate short-form 'ls' output for the given +branch+ and return it.
427
+ def make_shortform_ls_output( branch, options )
428
+ entries = branch.children.
429
+ collect {|b| b.rdn }.
430
+ sort_by {|rdn| rdn.downcase }
431
+ return columnize( entries ).
432
+ collect do |row|
433
+ row.gsub( /#{ATTRIBUTE_TYPE}=\s*\S+/ ) do |rdn|
434
+ format_rdn( rdn )
435
+ end
436
+ end
437
+ end
438
+
439
+
440
+ ### Change the current working DN to +rdn+.
441
+ def cdn_command( rdn=nil, *args )
442
+ if rdn.nil?
443
+ @currbranch = @dir.base
444
+ return
445
+ end
446
+
447
+ return self.parent_command if rdn == '..'
448
+
449
+ raise "invalid RDN %p" % [ rdn ] unless RELATIVE_DISTINGUISHED_NAME.match( rdn )
450
+
451
+ pairs = rdn.split( /\s*,\s*/ )
452
+ pairs.each do |dnpair|
453
+ self.log.debug " cd to %p" % [ dnpair ]
454
+ attribute, value = dnpair.split( /=/, 2 )
455
+ self.log.debug " changing to %s( %p )" % [ attribute, value ]
456
+ @currbranch = @currbranch.send( attribute, value )
457
+ end
458
+ end
459
+
460
+
461
+ ### Change the current working DN to the current entry's parent.
462
+ def parent_command( *args )
463
+ parent = @currbranch.parent or raise "%s is the root DN" % [ @currbranch.dn ]
464
+
465
+ self.log.debug " changing to %s" % [ parent.dn ]
466
+ @currbranch = parent
467
+ end
468
+
469
+
470
+ ### Edit the entry specified by +rdn+.
471
+ def edit_command( options, rdn )
472
+ branch = @currbranch.get_child( rdn )
473
+ entryhash = nil
474
+
475
+ if options.newentry
476
+ raise "#{branch.dn} already exists." if branch.exists?
477
+ object_classes = prompt_for_multiple_values( "Entry objectClasses:" )
478
+ entryhash = branch.valid_attributes_hash( *object_classes )
479
+ newhash = edit_in_yaml( entryhash )
480
+ args = object_classes + [newhash]
481
+ branch.create( *args )
482
+ else
483
+ raise "#{branch.dn}: no such entry. Did you mean to create it with -n?" unless
484
+ branch.exists?
485
+ entryhash = branch.entry
486
+ newhash = edit_in_yaml( entryhash )
487
+ branch.merge( entryhash )
488
+ end
489
+
490
+ message "Saved #{rdn}."
491
+ end
492
+ set_options :edit do |oparser, options|
493
+ oparser.banner = "edit [OPTIONS] DN"
494
+
495
+ oparser.on( "-n", "--new", FalseClass,
496
+ "Create a new entry instead of editing an existing one." ) do
497
+ options.newentry = true
498
+ end
499
+
500
+ end
501
+
502
+
503
+ ### Convert the given +patterns+ to branchsets relative to the current branch and return
504
+ ### them. This is used to map shell arguments like 'cn=*', 'Hosts', 'cn=dav*' into
505
+ ### branchsets that will find matching entries.
506
+ def convert_to_branchsets( *patterns )
507
+ self.log.debug "Turning %d patterns into branchsets." % [ patterns.length ]
508
+ return patterns.collect do |pat|
509
+ key, val = pat.split( /\s*=\s*/, 2 )
510
+ self.log.debug " making a filter out of %p => %p" % [ key, val ]
511
+ @currbranch.filter( key => val )
512
+ end
513
+ end
514
+
515
+
516
+ ### Remove the entry specified by +rdn+.
517
+ def rm_command( options, *rdns )
518
+ branchsets = self.convert_to_branchsets( *rdns )
519
+ coll = Treequel::BranchCollection.new( *branchsets )
520
+
521
+ branches = coll.all
522
+
523
+ msg = "About to delete the following entries:\n" +
524
+ branches.collect {|br| " #{br.dn}" }.join("\n")
525
+
526
+ ask_for_confirmation( msg ) do
527
+ branches.each do |branch|
528
+ branch.delete
529
+ message "Deleted #{branch.dn}."
530
+ end
531
+ end
532
+ end
533
+ set_options :rm do |oparser, options|
534
+ oparser.banner = "rm [DNs]"
535
+ end
536
+
537
+
538
+ ### Find entries that match the given filter_clauses.
539
+ def grep_command( options, *filter_clauses )
540
+ branchset = filter_clauses.inject( @currbranch ) do |branch, clause|
541
+ branch.filter( clause )
542
+ end
543
+
544
+ message "Searching for entries that match '#{branchset.to_s}'"
545
+
546
+ entries = branchset.all
547
+ output = columnize( entries ).
548
+ collect do |row|
549
+ row.gsub( /#{ATTRIBUTE_TYPE}=\s*\S+/ ) do |rdn|
550
+ format_rdn( rdn )
551
+ end
552
+ end
553
+ message( output )
554
+ end
555
+ set_options :grep do |oparser, options|
556
+ oparser.banner = "grep [OPTIONS] FILTER"
557
+ end
558
+
559
+
560
+ ### Bind as a user.
561
+ def bind_command( options, *args )
562
+ binddn = (args.first || prompt( "Bind DN/UID" )) or
563
+ raise "Cancelled."
564
+ password = prompt_for_password()
565
+
566
+ # Try to turn a non-DN into a DN
567
+ user = nil
568
+ if binddn.index( '=' )
569
+ user = Treequel::Branch.new( @dir, binddn )
570
+ else
571
+ user = @dir.filter( :uid => binddn ).first
572
+ end
573
+ raise "No user found for %p" % [ binddn ] unless user.exists?
574
+
575
+ @dir.bind( user, password )
576
+ message "Bound as #{user}"
577
+ end
578
+ set_options :bind do |oparser, options|
579
+ oparser.banner = "bind [BIND_DN or UID]"
580
+ oparser.separator "If you don't specify a BIND_DN, you will be prompted for it."
581
+ end
582
+
583
+
584
+ ### Handle a command from the user that doesn't exist.
585
+ def handle_missing_cmd( *args )
586
+ command = args.shift || '(testing?)'
587
+ message "Unknown command %p" % [ command ]
588
+ message "Known commands: ", ' ' + @commands.join(', ')
589
+ end
590
+
591
+
592
+ ### Find methods that implement commands and return them in a sorted Array.
593
+ def find_commands
594
+ return self.methods.
595
+ collect {|mname| mname.to_s }.
596
+ grep( /^(\w+)_command$/ ).
597
+ collect {|mname| mname[/^(\w+)_command$/, 1] }.
598
+ sort
599
+ end
600
+
601
+
602
+ #######
603
+ private
604
+ #######
605
+
606
+ ### Dump the specified +object+ to a file as YAML, invoke an editor on it, then undump the
607
+ ### result. If the file has changed, return the updated object, else returns +nil+.
608
+ def edit_in_yaml( object )
609
+ yaml = object.to_yaml.gsub( /^\s*$/, '' )
610
+ filename = Digest::SHA1.hexdigest( yaml )
611
+ tempfile = Tempfile.new( filename )
612
+
613
+ message "Object as YAML is: ", yaml
614
+ tempfile.print( yaml )
615
+ tempfile.close
616
+
617
+ new_yaml = edit( tempfile.path )
618
+
619
+ if new_yaml == yaml
620
+ message "Unchanged."
621
+ return nil
622
+ else
623
+ return YAML.load( new_yaml )
624
+ end
625
+ end
626
+
627
+
628
+ ### Create a command table that maps command abbreviations to the Method object that
629
+ ### implements it.
630
+ def make_command_table( commands )
631
+ table = commands.abbrev
632
+ table.keys.each do |abbrev|
633
+ mname = table.delete( abbrev )
634
+ table[ abbrev ] = self.method( mname + '_command' )
635
+ end
636
+
637
+ return table
638
+ end
639
+
640
+
641
+ ### Return the specified args as a string, quoting any that have a space.
642
+ def quotelist( *args )
643
+ return args.flatten.collect {|part| part =~ /\s/ ? part.inspect : part}
644
+ end
645
+
646
+
647
+ ### Run the specified command +cmd+ with system(), failing if the execution
648
+ ### fails.
649
+ def run_command( *cmd )
650
+ cmd.flatten!
651
+
652
+ if cmd.length > 1
653
+ self.log.debug( quotelist(*cmd) )
654
+ else
655
+ self.log.debug( cmd )
656
+ end
657
+
658
+ if $dryrun
659
+ self.log.error "(dry run mode)"
660
+ else
661
+ system( *cmd )
662
+ unless $?.success?
663
+ raise "Command failed: [%s]" % [cmd.join(' ')]
664
+ end
665
+ end
666
+ end
667
+
668
+
669
+ ### Run the given +cmd+ with the specified +args+ without interpolation by the shell and
670
+ ### return anything written to its STDOUT.
671
+ def read_command_output( cmd, *args )
672
+ self.log.debug "Reading output from: %s" % [ cmd, quotelist(cmd, *args) ]
673
+ output = IO.read( '|-' ) or exec cmd, *args
674
+ return output
675
+ end
676
+
677
+
678
+ ### Run a subordinate Rake process with the same options and the specified +targets+.
679
+ def rake( *targets )
680
+ opts = ARGV.select {|arg| arg[0,1] == '-' }
681
+ args = opts + targets.map {|t| t.to_s }
682
+ run 'rake', '-N', *args
683
+ end
684
+
685
+
686
+ ### Open a pipe to a process running the given +cmd+ and call the given block with it.
687
+ def pipeto( *cmd )
688
+ $DEBUG = true
689
+
690
+ cmd.flatten!
691
+ self.log.info( "Opening a pipe to: ", cmd.collect {|part| part =~ /\s/ ? part.inspect : part} )
692
+ if $dryrun
693
+ message "(dry run mode)"
694
+ else
695
+ open( '|-', 'w+' ) do |io|
696
+
697
+ # Parent
698
+ if io
699
+ yield( io )
700
+
701
+ # Child
702
+ else
703
+ exec( *cmd )
704
+ raise "Command failed: [%s]" % [cmd.join(' ')]
705
+ end
706
+ end
707
+ end
708
+ end
709
+
710
+
711
+ ### Return the fully-qualified path to the specified +program+ in the PATH.
712
+ def which( program )
713
+ ENV['PATH'].split(/:/).
714
+ collect {|dir| Pathname.new(dir) + program }.
715
+ find {|path| path.exist? && path.executable? }
716
+ end
717
+
718
+
719
+ ### Output the specified message +parts+.
720
+ def message( *parts )
721
+ $stderr.puts( *parts )
722
+ end
723
+
724
+
725
+ ### Output the specified <tt>msg</tt> as an ANSI-colored error message
726
+ ### (white on red).
727
+ def error_message( msg, details='' )
728
+ $stderr.puts colorize( 'bold', 'white', 'on_red' ) { msg } + ' ' + details
729
+ end
730
+ alias :error :error_message
731
+
732
+
733
+ ### Highlight and embed a prompt control character in the given +string+ and return it.
734
+ def make_prompt_string( string )
735
+ return CLEAR_CURRENT_LINE + colorize( 'bold', 'yellow' ) { string + ' ' }
736
+ end
737
+
738
+
739
+ ### Output the specified <tt>prompt_string</tt> as a prompt (in green) and
740
+ ### return the user's input with leading and trailing spaces removed. If a
741
+ ### test is provided, the prompt will repeat until the test returns true.
742
+ ### An optional failure message can also be passed in.
743
+ def prompt( prompt_string, failure_msg="Try again." ) # :yields: response
744
+ prompt_string.chomp!
745
+ prompt_string << ":" unless /\W$/.match( prompt_string )
746
+ response = nil
747
+
748
+ begin
749
+ prompt = make_prompt_string( prompt_string )
750
+ response = readline( prompt ) || ''
751
+ response.strip!
752
+ if block_given? && ! yield( response )
753
+ error_message( failure_msg + "\n\n" )
754
+ response = nil
755
+ end
756
+ end while response.nil?
757
+
758
+ return response
759
+ end
760
+
761
+
762
+ ### Prompt the user with the given <tt>prompt_string</tt> via #prompt,
763
+ ### substituting the given <tt>default</tt> if the user doesn't input
764
+ ### anything. If a test is provided, the prompt will repeat until the test
765
+ ### returns true. An optional failure message can also be passed in.
766
+ def prompt_with_default( prompt_string, default, failure_msg="Try again." )
767
+ response = nil
768
+
769
+ begin
770
+ default ||= '~'
771
+ response = prompt( "%s [%s]" % [ prompt_string, default ] )
772
+ response = default.to_s if !response.nil? && response.empty?
773
+
774
+ self.log.debug "Validating response %p" % [ response ]
775
+
776
+ # the block is a validator. We need to make sure that the user didn't
777
+ # enter '~', because if they did, it's nil and we should move on. If
778
+ # they didn't, then call the block.
779
+ if block_given? && response != '~' && ! yield( response )
780
+ error_message( failure_msg + "\n\n" )
781
+ response = nil
782
+ end
783
+ end while response.nil?
784
+
785
+ return nil if response == '~'
786
+ return response
787
+ end
788
+
789
+
790
+ ### Prompt for an array of values
791
+ def prompt_for_multiple_values( label, default=nil )
792
+ message( MULTILINE_PROMPT % [label] )
793
+ if default
794
+ message "Enter a single blank line to keep the default:\n %p" % [ default ]
795
+ end
796
+
797
+ results = []
798
+ result = nil
799
+
800
+ begin
801
+ result = readline( make_prompt_string("> ") )
802
+ if result.nil? || result.empty?
803
+ results << default if default && results.empty?
804
+ else
805
+ results << result
806
+ end
807
+ end until result.nil? || result.empty?
808
+
809
+ return results.flatten
810
+ end
811
+
812
+
813
+ ### Turn echo and masking of input on/off.
814
+ def noecho( masked=false )
815
+ rval = nil
816
+ term = Termios.getattr( $stdin )
817
+
818
+ begin
819
+ newt = term.dup
820
+ newt.c_lflag &= ~Termios::ECHO
821
+ newt.c_lflag &= ~Termios::ICANON if masked
822
+
823
+ Termios.tcsetattr( $stdin, Termios::TCSANOW, newt )
824
+
825
+ rval = yield
826
+ ensure
827
+ Termios.tcsetattr( $stdin, Termios::TCSANOW, term )
828
+ end
829
+
830
+ return rval
831
+ end
832
+
833
+
834
+ ### Prompt the user for her password, turning off echo if the 'termios' module is
835
+ ### available.
836
+ def prompt_for_password( prompt="Password: " )
837
+ rval = nil
838
+ noecho( true ) do
839
+ $stderr.print( prompt )
840
+ rval = ($stdin.gets || '').chomp
841
+ end
842
+ $stderr.puts
843
+ return rval
844
+ end
845
+
846
+
847
+ ### Display a description of a potentially-dangerous task, and prompt
848
+ ### for confirmation. If the user answers with anything that begins
849
+ ### with 'y', yield to the block. If +abort_on_decline+ is +true+,
850
+ ### any non-'y' answer will fail with an error message.
851
+ def ask_for_confirmation( description, abort_on_decline=true )
852
+ puts description
853
+
854
+ answer = prompt_with_default( "Continue?", 'n' ) do |input|
855
+ input =~ /^[yn]/i
856
+ end
857
+
858
+ if answer =~ /^y/i
859
+ return yield
860
+ elsif abort_on_decline
861
+ error "Aborted."
862
+ fail
863
+ end
864
+
865
+ return false
866
+ end
867
+ alias :prompt_for_confirmation :ask_for_confirmation
868
+
869
+
870
+ ### Search line-by-line in the specified +file+ for the given +regexp+, returning the
871
+ ### first match, or nil if no match was found. If the +regexp+ has any capture groups,
872
+ ### those will be returned in an Array, else the whole matching line is returned.
873
+ def find_pattern_in_file( regexp, file )
874
+ rval = nil
875
+
876
+ File.open( file, 'r' ).each do |line|
877
+ if (( match = regexp.match(line) ))
878
+ rval = match.captures.empty? ? match[0] : match.captures
879
+ break
880
+ end
881
+ end
882
+
883
+ return rval
884
+ end
885
+
886
+
887
+ ### Search line-by-line in the output of the specified +cmd+ for the given +regexp+,
888
+ ### returning the first match, or nil if no match was found. If the +regexp+ has any
889
+ ### capture groups, those will be returned in an Array, else the whole matching line
890
+ ### is returned.
891
+ def find_pattern_in_pipe( regexp, *cmd )
892
+ output = []
893
+
894
+ self.log.info( cmd.collect {|part| part =~ /\s/ ? part.inspect : part} )
895
+ Open3.popen3( *cmd ) do |stdin, stdout, stderr|
896
+ stdin.close
897
+
898
+ output << stdout.gets until stdout.eof?
899
+ output << stderr.gets until stderr.eof?
900
+ end
901
+
902
+ result = output.find { |line| regexp.match(line) }
903
+ return $1 || result
904
+ end
905
+
906
+
907
+ ### Invoke the user's editor on the given +filename+ and return the exit code
908
+ ### from doing so.
909
+ def edit( filename )
910
+ editor = ENV['EDITOR'] || ENV['VISUAL'] || DEFAULT_EDITOR
911
+ system editor, filename.to_s
912
+ unless $?.success? || editor =~ /vim/i
913
+ raise "Editor exited with an error status (%d)" % [ $?.exitstatus ]
914
+ end
915
+ return File.read( filename )
916
+ end
917
+
918
+
919
+ ### Make an easily-comparable version vector out of +ver+ and return it.
920
+ def vvec( ver )
921
+ return ver.split('.').collect {|char| char.to_i }.pack('N*')
922
+ end
923
+
924
+
925
+ ### Return an ANSI-colored version of the given +rdn+ string.
926
+ def format_rdn( rdn )
927
+ rdn.split( /,/ ).collect do |rdn|
928
+ key, val = rdn.split( /\s*=\s*/, 2 )
929
+ colorize( :white ) { key } +
930
+ colorize( :bold, :black ) { '=' } +
931
+ colorize( :bold, :white ) { val }
932
+ end.join( colorize(',', :green) )
933
+ end
934
+
935
+
936
+ ### Highlight LDIF and return it.
937
+ def format_ldif( ldif )
938
+ return ldif.gsub( /^(\S[^:]*)(::?)\s*(.*)$/ ) do
939
+ key, sep, val = $1, $2, $3
940
+ case sep
941
+ when '::'
942
+ colorize( :cyan ) { key } + ':: ' + colorize( :dark, :white ) { val }
943
+ when ':'
944
+ colorize( :bold, :cyan ) { key } + ': ' + colorize( :dark, :white ) { val }
945
+ else
946
+ key + sep + ' ' + val
947
+ end
948
+ end
949
+ end
950
+
951
+
952
+ ### Return the specified +entries+ as an Array of span-sorted columns fit to the
953
+ ### current terminal width.
954
+ def columnize( *entries )
955
+ return Columnize.columnize( entries.flatten, @columns, ' ' )
956
+ end
957
+
958
+ end # class Treequel::Shell
959
+
960
+
961
+ ldapuri = URI( ARGV.shift || 'ldap://localhost' )
962
+ Treequel::Shell.new( ldapuri ).run
963
+