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