treequel 1.0.0 → 1.0.1

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