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