treequel-shell 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ == v1.10.0 [2012-08-29] Michael Granger <ged@FaerieMUD.org>
2
+
3
+ Split out from the 'treequel' gem.
4
+
5
+
6
+
7
+
@@ -0,0 +1,8 @@
1
+ ChangeLog
2
+ History.rdoc
3
+ Manifest.txt
4
+ README.rdoc
5
+ Rakefile
6
+ bin/treeirb
7
+ bin/treequel
8
+ bin/treewhat
@@ -0,0 +1,61 @@
1
+ = Treequel-Shell
2
+
3
+ home :: https://bitbucket.org/ged/Treequel-Shell
4
+ code :: http://repo.deveiate.org/Treequel-Shell
5
+ docs :: http://deveiate.org/code/treequel-shell
6
+ github :: http://github.com/ged/treequel-shell
7
+
8
+
9
+ == Description
10
+
11
+ Treequel-Shell is a collection of LDAP tools based on the Treequel LDAP
12
+ toolkit.
13
+
14
+ It includes:
15
+
16
+ treequel :: an LDAP shell/editor; treat your LDAP directory like a filesystem!
17
+ treewhat :: an LDAP schema explorer. Dump objectClasses and attribute type info
18
+ in several convenient formats.
19
+
20
+
21
+ == Contributing
22
+
23
+ You can check out the current development source
24
+ {with Mercurial}[http://repo.deveiate.org/Treequel-Shell], or
25
+ if you prefer Git, via the project's
26
+ {Github mirror}[https://github.com/ged/treequel-shell].
27
+
28
+ You can submit bug reports, suggestions, and read more about future plans at
29
+ {the project page}[https://bitbucket.org/ged/Treequel-Shell].
30
+
31
+
32
+ == License
33
+
34
+ Copyright (c) 2008-2012, Michael Granger
35
+ All rights reserved.
36
+
37
+ Redistribution and use in source and binary forms, with or without
38
+ modification, are permitted provided that the following conditions are met:
39
+
40
+ * Redistributions of source code must retain the above copyright notice,
41
+ this list of conditions and the following disclaimer.
42
+
43
+ * Redistributions in binary form must reproduce the above copyright notice,
44
+ this list of conditions and the following disclaimer in the documentation
45
+ and/or other materials provided with the distribution.
46
+
47
+ * Neither the name of the author/s, nor the names of the project's
48
+ contributors may be used to endorse or promote products derived from this
49
+ software without specific prior written permission.
50
+
51
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
52
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
53
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
54
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
55
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
56
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
57
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
58
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
59
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
60
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
61
+
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env rake
2
+
3
+ begin
4
+ require 'hoe'
5
+ rescue LoadError
6
+ abort "This Rakefile requires hoe (gem install hoe)"
7
+ end
8
+
9
+ require 'rake/clean'
10
+
11
+ Hoe.plugin :mercurial
12
+ Hoe.plugin :signing
13
+ Hoe.plugin :deveiate
14
+
15
+ Hoe.plugins.delete :rubyforge
16
+
17
+ hoespec = Hoe.spec 'treequel-shell' do
18
+ self.readme_file = 'README.rdoc'
19
+ self.history_file = 'History.rdoc'
20
+ self.extra_rdoc_files = Rake::FileList[ '*.rdoc' ]
21
+ self.spec_extras[:rdoc_options] = ['-f', 'fivefish', '-t', 'Treequel']
22
+
23
+ self.need_tar = true
24
+ self.need_zip = true
25
+
26
+ self.developer 'Michael Granger', 'ged@FaerieMUD.org'
27
+
28
+ self.dependency 'loggability', '~> 0.5'
29
+ self.dependency 'treequel', '~> 1.10'
30
+ self.dependency 'highline', '~> 1.6'
31
+ self.dependency 'trollop', '~> 2.0'
32
+ self.dependency 'sysexits', '~> 1.0'
33
+
34
+ self.spec_extras[:licenses] = ["BSD"]
35
+ self.require_ruby_version( '>=1.8.7' )
36
+
37
+ self.hg_sign_tags = true if self.respond_to?( :hg_sign_tags= )
38
+ self.check_history_on_release = true if self.respond_to?( :check_history_on_release= )
39
+ self.rdoc_locations << "deveiate:/usr/local/www/public/code/#{remote_rdoc_dir}"
40
+ end
41
+
42
+ ENV['VERSION'] ||= hoespec.spec.version.to_s
43
+
44
+ # Ensure the specs pass before checking in
45
+ task 'hg:precheckin' => [ :check_history, :check_manifest ]
46
+
47
+
48
+ if Rake::Task.task_defined?( '.gemtest' )
49
+ Rake::Task['.gemtest'].clear
50
+ task '.gemtest' do
51
+ $stderr.puts "Not including a .gemtest until I'm confident the test suite is idempotent."
52
+ end
53
+ end
54
+
55
+ # Add admin app testing directories to the clobber list
56
+ CLOBBER.include( 'ChangeLog' )
57
+
58
+
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'irb'
4
+ require 'irb/extend-command'
5
+ require 'irb/cmd/nop'
6
+ require 'treequel'
7
+
8
+
9
+ if uri = ARGV.shift
10
+ $dir = Treequel.directory( uri )
11
+ else
12
+ $dir = Treequel.directory_from_config
13
+ end
14
+
15
+ $stderr.puts "Directory is in $dir:", ' ' + $dir.inspect
16
+
17
+ IRB.start( $0 )
18
+
@@ -0,0 +1,1231 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'abbrev'
4
+ require 'columnize'
5
+ require 'diff/lcs'
6
+ require 'digest/sha1'
7
+ require 'irb'
8
+ require 'open3'
9
+ require 'optparse'
10
+ require 'ostruct'
11
+ require 'loggability'
12
+ require 'pathname'
13
+ require 'readline'
14
+ require 'shellwords'
15
+ require 'tempfile'
16
+ require 'uri'
17
+ require 'yaml'
18
+ require 'termios'
19
+ require 'terminfo'
20
+
21
+ require 'treequel'
22
+ require 'treequel/mixins'
23
+ require 'treequel/constants'
24
+
25
+
26
+ # The Treequel shell.
27
+ #
28
+ # TODO:
29
+ # * Make more commands use the convert_to_branchsets utility function
30
+ #
31
+ class Treequel::Shell
32
+ include Readline,
33
+ Treequel::Loggable,
34
+ Treequel::ANSIColorUtilities,
35
+ Treequel::Constants::Patterns,
36
+ Treequel::HashUtilities
37
+
38
+ extend Treequel::ANSIColorUtilities
39
+
40
+ # Gem version constant
41
+ VERSION = '1.10.0'
42
+
43
+ # Prompt text for #prompt_for_multiple_values
44
+ MULTILINE_PROMPT = <<-'EOF'
45
+ Enter one or more values for '%s'.
46
+ A blank line finishes input.
47
+ EOF
48
+
49
+ # Some ANSI codes for fancier stuff
50
+ CLEAR_TO_EOL = "\e[K"
51
+ CLEAR_CURRENT_LINE = "\e[2K"
52
+
53
+ # Valid connect-type arguments
54
+ VALID_CONNECT_TYPES = %w[tls ssl plain]
55
+
56
+ # Command option parsers
57
+ @@option_parsers = {}
58
+
59
+ # Path to the default history file
60
+ HISTORY_FILE = Pathname( "~/.treequel.history" )
61
+
62
+ # Number of items to store in history by default
63
+ DEFAULT_HISTORY_SIZE = 100
64
+
65
+ # The default editor, in case ENV['VISUAL'] and ENV['EDITOR'] are unset
66
+ DEFAULT_EDITOR = 'vi'
67
+
68
+
69
+ #################################################################
70
+ ### C L A S S M E T H O D S
71
+ #################################################################
72
+
73
+ ### Run the shell.
74
+ def self::run( args )
75
+ Loggability.format_with( :color ) if $stdout.tty?
76
+
77
+ bind_as, plaintext, uri = self.parse_options( args )
78
+ connect_type = plaintext ? :plain : :tls
79
+
80
+ directory = if uri
81
+ Treequel.directory( uri, :connect_type => connect_type )
82
+ else
83
+ Treequel.directory_from_config
84
+ end
85
+
86
+ new( directory ).run( bind_as )
87
+ end
88
+
89
+
90
+ ### Parse command-line options for shell startup and return an options struct and
91
+ ### the LDAP URI.
92
+ def self::parse_options( argv )
93
+ progname = File.basename( $0 )
94
+ loglevels = Loggability::LOG_LEVELS.
95
+ sort_by {|_,lvl| lvl }.
96
+ collect {|name,lvl| name.to_s }.
97
+ join(', ')
98
+ bind_as = nil
99
+ plaintext = false
100
+
101
+ oparser = OptionParser.new( "Usage: #{progname} [OPTIONS] [LDAPURL]" ) do |oparser|
102
+ oparser.separator ' '
103
+
104
+ oparser.on( '--binddn=DN', '-b DN', String, "Bind as DN" ) do |dn|
105
+ bind_as = dn
106
+ end
107
+
108
+ oparser.on( '--no-tls', FalseClass, "Use a plaintext (unencrypted) connection.",
109
+ "If you don't specify a connection URL, this option is ignored." ) do
110
+ plaintext = true
111
+ end
112
+
113
+ oparser.on( '--loglevel=LEVEL', '-l LEVEL', Loggability::LOG_LEVELS.keys,
114
+ "Set the logging level. Should be one of:", loglevels ) do |lvl|
115
+ Loggability.level = lvl
116
+ end
117
+
118
+ oparser.on( '--debug', '-d', FalseClass, "Turn debugging on" ) do
119
+ $DEBUG = true
120
+ $trace = true
121
+ Loggability.level = :debug
122
+ end
123
+
124
+ oparser.on("-h", "--help", "Show this help message.") do
125
+ $stderr.puts( oparser )
126
+ exit!
127
+ end
128
+ end
129
+
130
+ remaining_args = oparser.parse( argv )
131
+
132
+ return bind_as, plaintext, *remaining_args
133
+ end
134
+
135
+
136
+ ### Create an option parser from the specified +block+ for the given +command+ and register
137
+ ### it. Many thanks to apeiros and dominikh on #Ruby-Pro for the ideas behind this.
138
+ def self::set_options( command, &block )
139
+ options = OpenStruct.new
140
+ oparser = OptionParser.new( "Help for #{command}" ) do |o|
141
+ yield( o, options )
142
+ end
143
+ oparser.default_argv = []
144
+
145
+ @@option_parsers[command.to_sym] = [oparser, options]
146
+ end
147
+
148
+
149
+ #################################################################
150
+ ### I N S T A N C E M E T H O D S
151
+ #################################################################
152
+
153
+ ### Create a new shell for the specified +directory+ (a Treequel::Directory).
154
+ def initialize( directory )
155
+ @dir = directory
156
+ @uri = directory.uri
157
+ @quit = false
158
+ @currbranch = @dir
159
+ @columns = TermInfo.screen_width
160
+ @rows = TermInfo.screen_height
161
+
162
+ @commands = self.find_commands
163
+ @completions = @commands.abbrev
164
+ @command_table = make_command_table( @commands )
165
+ end
166
+
167
+
168
+ ######
169
+ public
170
+ ######
171
+
172
+ # The number of columns in the current terminal
173
+ attr_reader :columns
174
+
175
+ # The number of rows in the current terminal
176
+ attr_reader :rows
177
+
178
+ # The flag which causes the shell to exit after the current loop
179
+ attr_accessor :quit
180
+
181
+
182
+ ### The command loop: run the shell until the user wants to quit, binding as +bind_as+ if
183
+ ### given.
184
+ def run( bind_as=nil )
185
+ @original_tty_settings = IO.read( '|-' ) or exec 'stty', '-g'
186
+ message "Connected to %s" % [ @uri ]
187
+
188
+ # Set up the completion callback
189
+ self.setup_completion
190
+
191
+ # Load saved command-line history
192
+ self.read_history
193
+
194
+ # If the user said to bind as someone on the command line, invoke a
195
+ # 'bind' command before dropping into the command line
196
+ if bind_as
197
+ options = OpenStruct.new # dummy options object
198
+ self.bind_command( options, bind_as )
199
+ end
200
+
201
+ # Run until something sets the quit flag
202
+ until @quit
203
+ $stderr.puts
204
+ prompt = make_prompt_string( @currbranch.dn + '> ' )
205
+ input = Readline.readline( prompt, true )
206
+ self.log.debug "Input is: %p" % [ input ]
207
+
208
+ # EOL makes the shell quit
209
+ if input.nil?
210
+ self.log.debug "EOL: setting quit flag"
211
+ @quit = true
212
+
213
+ # Blank input -- just reprompt
214
+ elsif input == ''
215
+ self.log.debug "No command. Re-displaying the prompt."
216
+
217
+ # Parse everything else into command + args
218
+ else
219
+ self.log.debug "Dispatching input: %p" % [ input ]
220
+ self.dispatch_cmd( input )
221
+ end
222
+ end
223
+
224
+ message "\nSaving history...\n"
225
+ self.save_history
226
+
227
+ message "done."
228
+
229
+ rescue => err
230
+ error_message( err.class.name, err.message )
231
+ err.backtrace.each do |frame|
232
+ self.log.debug " " + frame
233
+ end
234
+
235
+ ensure
236
+ system( 'stty', @original_tty_settings.chomp )
237
+ end
238
+
239
+
240
+ ### Parse the specified +input+ into a command, options, and arguments and dispatch them
241
+ ### to the appropriate command method.
242
+ def dispatch_cmd( input )
243
+ command, *args = Shellwords.shellwords( input )
244
+
245
+ # If it's a valid command, run it
246
+ if meth = @command_table[ command ]
247
+ full_command = @completions[ command ].to_sym
248
+
249
+ # If there's a registered optionparser for the command, use it to
250
+ # split out options and arguments, then pass those to the command.
251
+ if @@option_parsers.key?( full_command )
252
+ oparser, options = @@option_parsers[ full_command ]
253
+ self.log.debug "Got an option-parser for #{full_command}."
254
+
255
+ cmdargs = oparser.parse( args )
256
+ self.log.debug " options=%p, args=%p" % [ options, cmdargs ]
257
+ meth.call( options, *cmdargs )
258
+
259
+ options.clear
260
+
261
+ # ...otherwise just call it with all the args.
262
+ else
263
+ self.log.warn " no options defined for '%s' command" % [ command ]
264
+ meth.call( *args )
265
+ end
266
+
267
+ # ...otherwise call the fallback handler
268
+ else
269
+ self.handle_missing_cmd( command )
270
+ end
271
+
272
+ rescue LDAP::ResultError => err
273
+ case err.message
274
+ when /can't contact ldap server/i
275
+ if @dir.connected?
276
+ error_message( "LDAP connection went away." )
277
+ else
278
+ error_message( "Couldn't connect to the server." )
279
+ end
280
+ ask_for_confirmation( "Attempt to reconnect?" ) do
281
+ @dir.reconnect
282
+ end
283
+ retry
284
+
285
+ when /invalid credentials/i
286
+ error_message( "Authentication failed." )
287
+ else
288
+ error_message( err.class.name, err.message )
289
+ self.log.debug { " " + err.backtrace.join(" \n") }
290
+ end
291
+
292
+ rescue => err
293
+ error_message( err.message )
294
+ self.log.debug { " " + err.backtrace.join(" \n") }
295
+ end
296
+
297
+
298
+
299
+ #########
300
+ protected
301
+ #########
302
+
303
+ ### Set up Readline completion
304
+ def setup_completion
305
+ Readline.completion_proc = self.method( :completion_callback ).to_proc
306
+ Readline.completer_word_break_characters = ''
307
+ Readline.basic_word_break_characters = ''
308
+ end
309
+
310
+
311
+ ### Read command line history from HISTORY_FILE
312
+ def read_history
313
+ histfile = HISTORY_FILE.expand_path
314
+
315
+ if histfile.exist?
316
+ lines = histfile.readlines.collect {|line| line.chomp }
317
+ self.log.debug "Read %d saved history commands from %s." % [ lines.length, histfile ]
318
+ Readline::HISTORY.push( *lines )
319
+ else
320
+ self.log.debug "History file '%s' was empty or non-existant." % [ histfile ]
321
+ end
322
+ end
323
+
324
+
325
+ ### Save command line history to HISTORY_FILE
326
+ def save_history
327
+ histfile = HISTORY_FILE.expand_path
328
+
329
+ lines = Readline::HISTORY.to_a.reverse.uniq.reverse
330
+ lines = lines[ -DEFAULT_HISTORY_SIZE, DEFAULT_HISTORY_SIZE ] if
331
+ lines.length > DEFAULT_HISTORY_SIZE
332
+
333
+ self.log.debug "Saving %d history lines to %s." % [ lines.length, histfile ]
334
+
335
+ histfile.open( File::WRONLY|File::CREAT|File::TRUNC ) do |ofh|
336
+ ofh.puts( *lines )
337
+ end
338
+ end
339
+
340
+
341
+ ### Handle completion requests from Readline.
342
+ def completion_callback( input )
343
+ self.log.debug "Input completion: %p" % [ input ]
344
+ parts = Shellwords.shellwords( input )
345
+
346
+ # If there aren't any arguments, it's command completion
347
+ if parts.empty?
348
+ possible_completions = @commands.sort
349
+ self.log.debug " possible completions: %p" % [ possible_completions ]
350
+ return possible_completions
351
+ elsif parts.length == 1
352
+ # One completion means it's an unambiguous match, so just complete it.
353
+ possible_completions = @commands.grep( /^#{Regexp.quote(input)}/ ).sort
354
+ self.log.debug " possible completions: %p" % [ possible_completions ]
355
+ return possible_completions
356
+ else
357
+ incomplete = parts.pop
358
+ self.log.debug " the incomplete bit is: %p" % [ incomplete ]
359
+ possible_completions = @currbranch.children.
360
+ collect {|br| br.rdn }.grep( /^#{Regexp.quote(incomplete)}/i ).sort
361
+
362
+ possible_completions.map! do |lastpart|
363
+ parts.join( ' ' ) + ' ' + lastpart
364
+ end
365
+
366
+ self.log.debug " possible (argument) completions: %p" % [ possible_completions ]
367
+ return possible_completions
368
+ end
369
+ end
370
+
371
+
372
+ #################################################################
373
+ ### C O M M A N D S
374
+ #################################################################
375
+
376
+ ### Show the completions hash
377
+ def show_completions_command
378
+ message "Completions:", @completions.inspect
379
+ end
380
+ set_options :show_completions do |oparser, options|
381
+ oparser.banner = "show_completions"
382
+ oparser.separator 'Show the list of command completions (for debugging the shell)'
383
+ end
384
+
385
+
386
+ ### Show help text for the specified command, or a list of all available commands
387
+ ### if none is specified.
388
+ def help_command( options, *args )
389
+ if args.empty?
390
+ $stderr.puts
391
+ message colorize( "Available commands", :bold, :white ),
392
+ *columnize(@commands)
393
+ else
394
+ cmd = args.shift
395
+ full_command = @completions[ cmd ]
396
+
397
+ if @@option_parsers.key?( full_command.to_sym )
398
+ oparser, _ = @@option_parsers[ full_command.to_sym ]
399
+ self.log.debug "Setting summary width to: %p" % [ @columns ]
400
+ oparser.summary_width = @columns
401
+ output = oparser.to_s.sub( /^(.*?)\n/ ) do |match|
402
+ colorize( :bold, :white ) { match }
403
+ end
404
+
405
+ $stderr.puts
406
+ message( output )
407
+ else
408
+ error_message( "No help for '#{cmd}'" )
409
+ end
410
+ end
411
+ end
412
+ set_options :help do |oparser, options|
413
+ oparser.banner = "help [COMMAND]"
414
+ oparser.separator 'Display general help, or help for a specific COMMAND.'
415
+ end
416
+
417
+
418
+ ### Quit the shell.
419
+ def quit_command( options, *args )
420
+ message "Okay, exiting."
421
+ self.quit = true
422
+ end
423
+ set_options :quit do |oparser, options|
424
+ oparser.banner = "quit"
425
+ oparser.separator 'Exit the shell.'
426
+ end
427
+
428
+
429
+ ### Set the logging level (if invoked with an argument) or display the current
430
+ ### level (with no argument).
431
+ def log_command( options, *args )
432
+ newlevel = args.shift
433
+ if newlevel
434
+ if Loggability::LOG_LEVELS.key?( newlevel.to_sym )
435
+ Loggability.level = newlevel
436
+ message "Set log level to: %s" % [ newlevel ]
437
+ else
438
+ levelnames = Loggability::LOG_LEVELS.keys.sort.join(', ')
439
+ raise "Invalid log level %p: valid values are:\n %s" % [ newlevel, levelnames ]
440
+ end
441
+ else
442
+ message "Log level is currently: %s" % [ Loggability[Treequel].level ]
443
+ end
444
+ end
445
+ set_options :log do |oparser, options|
446
+ oparser.banner = "log [LEVEL]"
447
+ oparser.separator 'Set the logging level, or display the current level if no level ' +
448
+ "is given. Valid log levels are: %s" %
449
+ Loggability::LOG_LEVEL_NAMES.keys.sort.join(', ')
450
+ end
451
+
452
+
453
+ ### Display LDIF for the specified RDNs.
454
+ def cat_command( options, *args )
455
+ validate_rdns( *args )
456
+ args.each do |rdn|
457
+ extended = rdn.chomp!( '+' )
458
+
459
+ branch = @currbranch.get_child( rdn )
460
+ branch.include_operational_attrs = true if extended
461
+
462
+ if branch.exists?
463
+ ldifstring = branch.to_ldif( self.columns - 2 )
464
+ self.log.debug "LDIF: #{ldifstring.dump}"
465
+
466
+ message( format_ldif(ldifstring) )
467
+ else
468
+ error_message( "No such entry %s" % [branch.dn] )
469
+ end
470
+ end
471
+ end
472
+ set_options :cat do |oparser, options|
473
+ oparser.banner = "cat [RDN]+"
474
+ oparser.separator 'Display the entries specified by RDN as LDIF.'
475
+ end
476
+
477
+
478
+ ### Display YAML for the specified RDNs.
479
+ def yaml_command( options, *args )
480
+ validate_rdns( *args )
481
+ args.each do |rdn|
482
+ branch = @currbranch.get_child( rdn )
483
+ message( branch_as_yaml(branch) )
484
+ end
485
+ end
486
+ set_options :yaml do |oparser, options|
487
+ oparser.banner = "yaml [RDN]+"
488
+ oparser.separator 'Display the entries specified by RDN as YAML.'
489
+ end
490
+
491
+
492
+ ### List the children of the branch specified by the given +rdn+, or the current branch if none
493
+ ### are specified.
494
+ def ls_command( options, *args )
495
+ targets = []
496
+
497
+ # No argument, just use the current branch
498
+ if args.empty?
499
+ targets << @currbranch
500
+
501
+ # Otherwise, list each one specified
502
+ else
503
+ validate_rdns( *args )
504
+ args.each do |rdn|
505
+ if branch = @currbranch.get_child( rdn )
506
+ targets << branch
507
+ else
508
+ error_message( "cannot access #{rdn}: no such entry" )
509
+ end
510
+ end
511
+ end
512
+
513
+ # Fetch each branch's children, sort them, format them in columns, and highlight them
514
+ targets.each do |branch|
515
+ header( branch.dn ) if targets.length > 1
516
+ if options.longform
517
+ message self.make_longform_ls_output( branch, options )
518
+ else
519
+ message self.make_shortform_ls_output( branch, options )
520
+ end
521
+ message if targets.length > 1
522
+ end
523
+ end
524
+ set_options :ls do |oparser, options|
525
+ oparser.banner = "ls [OPTIONS] [DN]+"
526
+ oparser.separator 'List the entries specified, or the current entry if none are specified.'
527
+ oparser.separator ''
528
+
529
+ oparser.on( "-l", "--long", FalseClass, "List in long format." ) do
530
+ options.longform = true
531
+ end
532
+ oparser.on( "-t", "--timesort", FalseClass,
533
+ "Sort by time modified (most recently modified first)." ) do
534
+ options.timesort = true
535
+ end
536
+ oparser.on( "-d", "--dirsort", FalseClass,
537
+ "Sort entries with subordinate entries before those without." ) do
538
+ options.dirsort = true
539
+ end
540
+ oparser.on( "-r", "--reverse", FalseClass, "Reverse the entry sort functions." ) do
541
+ options.reversesort = true
542
+ end
543
+
544
+ end
545
+
546
+
547
+ ### Change the current working DN to +rdn+.
548
+ def cdn_command( options, rdn=nil, *args )
549
+ if rdn.nil?
550
+ @currbranch = @dir.base
551
+ return
552
+ end
553
+
554
+ return self.parent_command( options ) if rdn == '..'
555
+
556
+ validate_rdns( rdn )
557
+
558
+ pairs = rdn.split( /\s*,\s*/ )
559
+ pairs.each do |dnpair|
560
+ self.log.debug " cd to %p" % [ dnpair ]
561
+ attribute, value = dnpair.split( /=/, 2 )
562
+ self.log.debug " changing to %s( %p )" % [ attribute.downcase, value ]
563
+ @currbranch = @currbranch.send( attribute.downcase, value )
564
+ end
565
+ end
566
+ set_options :cdn do |oparser, options|
567
+ oparser.banner = "cdn <RDN>"
568
+ oparser.separator 'Change the current entry to <RDN>.'
569
+ end
570
+
571
+
572
+ ### Change the current working DN to the current entry's parent.
573
+ def parent_command( options, *args )
574
+ parent = @currbranch.parent or raise "%s is the root DN" % [ @currbranch.dn ]
575
+
576
+ self.log.debug " changing to %s" % [ parent.dn ]
577
+ @currbranch = parent
578
+ end
579
+ set_options :parent do |oparser, options|
580
+ oparser.banner = "parent"
581
+ oparser.separator "Change to the current entry's parent."
582
+ end
583
+
584
+
585
+ # ### Create the entry specified by +rdn+.
586
+ def create_command( options, rdn )
587
+ validate_rdns( rdn )
588
+ branch = @currbranch.get_child( rdn )
589
+
590
+ raise "#{branch.dn}: already exists." if branch.exists?
591
+ create_new_entry( branch )
592
+ end
593
+ set_options :create do |oparser, options|
594
+ oparser.banner = "create <RDN>"
595
+ oparser.separator "Create a new entry at <RDN>."
596
+ end
597
+
598
+
599
+ ### Edit the entry specified by +rdn+.
600
+ def edit_command( options, rdn )
601
+ validate_rdns( rdn )
602
+ branch = @currbranch.get_child( rdn )
603
+
604
+ raise "#{branch.dn}: no such entry. Did you mean to 'create' it instead? " unless
605
+ branch.exists?
606
+
607
+ if entryhash = edit_in_yaml( branch )
608
+ branch.merge( entryhash )
609
+ end
610
+
611
+ message "Saved #{rdn}."
612
+ end
613
+ set_options :edit do |oparser, options|
614
+ oparser.banner = "edit <RDN>"
615
+ oparser.separator "Edit the entry at RDN as YAML."
616
+ end
617
+
618
+
619
+ ### Change the DN of an entry
620
+ def mv_command( options, rdn, newdn )
621
+ validate_rdns( rdn, newdn )
622
+ branch = @currbranch.get_child( rdn )
623
+
624
+ raise "#{branch.dn}: no such entry" unless branch.exists?
625
+ olddn = branch.dn
626
+ branch.move( newdn )
627
+ message " %s -> %s: success" % [ olddn, branch.dn ]
628
+ end
629
+ set_options :mv do |oparser, options|
630
+ oparser.banner = "mv <RDN> <NEWRDN>"
631
+ oparser.separator "Move the entry at RDN to NEWRDN"
632
+ end
633
+
634
+
635
+ ### Copy an entry
636
+ def cp_command( options, rdn, newrdn )
637
+ # Can't validate as RDNs because they might be full DNs
638
+
639
+ base_dn = @currbranch.directory.base_dn
640
+
641
+ # If the RDN includes the base, it's a DN
642
+ branch = if rdn =~ /,#{base_dn}$/i
643
+ Treequel::Branch.new( @currbranch.directory, rdn )
644
+ else
645
+ @currbranch.get_child( rdn )
646
+ end
647
+
648
+ # The source should already exist
649
+ raise "#{branch.dn}: no such entry" unless branch.exists?
650
+
651
+ # Same for the other RDN...
652
+ newbranch = if newrdn =~ /,#{base_dn}$/i
653
+ Treequel::Branch.new( @currbranch.directory, newrdn )
654
+ else
655
+ @currbranch.get_child( newrdn )
656
+ end
657
+
658
+ # But it *shouldn't* exist already
659
+ raise "#{newbranch.dn}: already exists" if newbranch.exists?
660
+
661
+ attributes = branch.entry.merge( :dn => newbranch.dn )
662
+ newbranch.create( attributes )
663
+
664
+ message " %s -> %s: success" % [ rdn, branch.dn ]
665
+ end
666
+ set_options :cp do |oparser, options|
667
+ oparser.banner = "cp <RDN> <NEWRDN>"
668
+ oparser.separator "Copy the entry at RDN to a new entry at NEWRDN"
669
+ end
670
+
671
+
672
+ ### Remove the entry specified by +rdn+.
673
+ def rm_command( options, *rdns )
674
+ validate_rdns( *rdns )
675
+ branchsets = self.convert_to_branchsets( *rdns )
676
+ coll = Treequel::BranchCollection.new( *branchsets )
677
+
678
+ branches = coll.all
679
+
680
+ msg = "About to delete the following entries:\n" +
681
+ columnize( branches.collect {|br| br.dn } )
682
+
683
+ if options.force
684
+ branches.each do |br|
685
+ br.directory.delete( br )
686
+ message " delete %s: success" % [ br.dn ]
687
+ end
688
+ else
689
+ ask_for_confirmation( msg ) do
690
+ branches.each do |br|
691
+ br.directory.delete( br )
692
+ message " delete %s: success" % [ br.dn ]
693
+ end
694
+ end
695
+ end
696
+ end
697
+ set_options :rm do |oparser, options|
698
+ oparser.banner = "rm <RDN>+"
699
+ oparser.separator 'Remove the entries at the given RDNs.'
700
+
701
+ oparser.on( '-f', '--force', TrueClass, "Force -- remove without confirmation." ) do
702
+ options.force = true
703
+ end
704
+ end
705
+
706
+
707
+ ### Find entries that match the given filter_clauses.
708
+ def grep_command( options, *filter_clauses )
709
+ branchset = filter_clauses.inject( @currbranch ) do |branch, clause|
710
+ branch.filter( clause )
711
+ end
712
+
713
+ message "Searching for entries that match '#{branchset.to_s}'"
714
+
715
+ entries = branchset.all
716
+ output = columnize( entries ).gsub( /#{ATTRIBUTE_TYPE}=\s*\S+/ ) do |rdn|
717
+ format_rdn( rdn )
718
+ end
719
+ message( output )
720
+ end
721
+ set_options :grep do |oparser, options|
722
+ oparser.banner = "grep [OPTIONS] <FILTER>"
723
+ oparser.separator 'Search for children of the current entry that match the given FILTER'
724
+
725
+ oparser.on( '-r', '--recursive', TrueClass, "Search recursively." ) do
726
+ options.force = true
727
+ end
728
+ end
729
+
730
+
731
+ ### Show who the shell is currently bound as.
732
+ def whoami_command( options, *args )
733
+ if user = @dir.bound_user
734
+ message "Bound as #{user}"
735
+ else
736
+ message "Bound anonymously"
737
+ end
738
+ end
739
+ set_options :whoami do |oparser, options|
740
+ oparser.banner = "whoami"
741
+ oparser.separator 'Display the DN of the user the shell is bound as.'
742
+ end
743
+
744
+
745
+ ### Bind as a user.
746
+ def bind_command( options, *args )
747
+ binddn = (args.first || prompt( "Bind DN/UID" )) or
748
+ raise "Cancelled."
749
+ password = prompt_for_password()
750
+
751
+ # Try to turn a non-DN into a DN
752
+ user = nil
753
+ if binddn.index( '=' )
754
+ user = Treequel::Branch.new( @dir, binddn )
755
+ else
756
+ user = @dir.filter( :uid => binddn ).first
757
+ end
758
+
759
+ @dir.bind( user, password )
760
+ message "Bound as #{user}"
761
+ end
762
+ set_options :bind do |oparser, options|
763
+ oparser.banner = "bind [BIND_DN or UID]"
764
+ oparser.separator "Bind as BIND_DN or UID"
765
+ oparser.separator "If you don't specify a BIND_DN, you will be prompted for it."
766
+ end
767
+
768
+
769
+ ### Start an IRB session on either the current branchset, if invoked with no arguments, or
770
+ ### on a branchset for the specified +rdn+ if one is given.
771
+ def irb_command( options, *args )
772
+ branch = nil
773
+ if args.empty?
774
+ branch = @currbranch
775
+ else
776
+ rdn = args.first
777
+ validate_rdns( rdn )
778
+ branch = @currbranch.get_child( rdn )
779
+ end
780
+
781
+ self.log.debug "Setting up IRb shell"
782
+ IRB.start_session( branch )
783
+ end
784
+ set_options :irb do |oparser, options|
785
+ oparser.banner = "irb [RDN]"
786
+ oparser.separator "Start an IRb shell with either the current branch (if none is " +
787
+ "specified) or a branch for the entry specified by the given RDN."
788
+ end
789
+
790
+
791
+ ### Handle a command from the user that doesn't exist.
792
+ def handle_missing_cmd( *args )
793
+ command = args.shift || '(testing?)'
794
+ message "Unknown command %p" % [ command ]
795
+ message "Known commands: ", ' ' + @commands.join(', ')
796
+ end
797
+
798
+
799
+ ### Find methods that implement commands and return them in a sorted Array.
800
+ def find_commands
801
+ return self.methods.
802
+ collect {|mname| mname.to_s }.
803
+ grep( /^(\w+)_command$/ ).
804
+ collect {|mname| mname[/^(\w+)_command$/, 1] }.
805
+ sort
806
+ end
807
+
808
+
809
+ #################################################################
810
+ ### U T I L I T Y M E T H O D S
811
+ #################################################################
812
+
813
+ ### Convert the given +patterns+ to branchsets relative to the current branch and return
814
+ ### them. This is used to map shell arguments like 'cn=*', 'Hosts', 'cn=dav*' into
815
+ ### branchsets that will find matching entries.
816
+ def convert_to_branchsets( *patterns )
817
+ self.log.debug "Turning %d patterns into branchsets." % [ patterns.length ]
818
+ return patterns.collect do |pat|
819
+ key, val = pat.split( /\s*=\s*/, 2 )
820
+ self.log.debug " making a filter out of %p => %p" % [ key, val ]
821
+ @currbranch.filter( key => val )
822
+ end
823
+ end
824
+
825
+
826
+ ### Generate long-form output lines for the 'ls' command for the given +branch+.
827
+ def make_longform_ls_output( branch, options )
828
+ children = branch.children
829
+ totalmsg = "total %d" % [ children.length ]
830
+
831
+ # Calcuate column widths
832
+ oclen = children.map do |subbranch|
833
+ subbranch.include_operational_attrs = true
834
+ subbranch[:structuralObjectClass] ? subbranch[:structuralObjectClass].length : 0
835
+ end.max
836
+
837
+ # Set up sorting by collecting all the requested sort criteria as Proc objects which
838
+ # will be applied
839
+ sortfuncs = []
840
+ sortfuncs << lambda {|subbranch| subbranch[:hasSubordinates] ? 0 : 1 } if options.dirsort
841
+ sortfuncs << lambda {|subbranch| subbranch[:modifyTimestamp] } if options.timesort
842
+ sortfuncs << lambda {|subbranch| subbranch.rdn.downcase }
843
+
844
+ rows = children.
845
+ sort_by {|subbranch| sortfuncs.collect {|func| func.call(subbranch) } }.
846
+ collect {|subbranch| self.format_description(subbranch, oclen) }
847
+
848
+ return [ totalmsg ] + (options.reversesort ? rows.reverse : rows)
849
+ end
850
+
851
+
852
+ ### Generate short-form 'ls' output for the given +branch+ and return it.
853
+ def make_shortform_ls_output( branch, options )
854
+ branch.include_operational_attrs = true
855
+ entries = branch.children.
856
+ collect {|b| b.rdn + (b[:hasSubordinates] ? '/' : '') }.
857
+ sort_by {|rdn| rdn.downcase }
858
+ self.log.debug "Displaying %d entries in short form." % [ entries.length ]
859
+
860
+ return columnize( entries ).gsub( /#{ATTRIBUTE_TYPE}=\s*\S+/ ) do |rdn|
861
+ format_rdn( rdn )
862
+ end
863
+ end
864
+
865
+
866
+ ### Return the description of the specified +branch+ suitable for displaying in
867
+ ### the directory listing. The +oclen+ is the width of the objectclass column.
868
+ def format_description( branch, oclen=40 )
869
+ rdn = format_rdn( branch.rdn )
870
+ metadatalen = oclen + 16 + 6 # oc + timestamp + whitespace
871
+ maxdesclen = self.columns - metadatalen - rdn.length - 5
872
+
873
+ modtime = branch[:modifyTimestamp] || branch[:createTimestamp]
874
+ return "%#{oclen}s %s %s%s %s" % [
875
+ branch[:structuralObjectClass] || '',
876
+ modtime.strftime('%Y-%m-%d %H:%M'),
877
+ rdn,
878
+ branch[:hasSubordinates] ? '/' : '',
879
+ single_line_description( branch, maxdesclen )
880
+ ]
881
+ end
882
+
883
+
884
+ ### Generate a single-line description from the specified +branch+
885
+ def single_line_description( branch, maxlen=80 )
886
+ return '' unless branch[:description] && branch[:description].first
887
+ desc = branch[:description].join('; ').gsub( /\n+/, '' )
888
+ desc[ maxlen..desc.length ] = '...' if desc.length > maxlen
889
+ return '(' + desc + ')'
890
+ end
891
+
892
+
893
+ ### Create a new entry in the directory for the specified +branch+.
894
+ def create_new_entry( branch )
895
+ raise "#{branch.dn} already exists." if branch.exists?
896
+
897
+ # Prompt for the list of included objectClasses and build the appropriate
898
+ # blank entry with them in mind.
899
+ completions = branch.directory.schema.object_classes.keys.collect {|oid| oid.to_s }
900
+ self.log.debug "Prompting for new entry object classes with %d completions." %
901
+ [ completions.length ]
902
+ object_classes = prompt_for_multiple_values( "Entry objectClasses:", nil, completions ).
903
+ collect {|arg| arg.strip }.compact
904
+ self.log.debug " user wants %d objectclasses: %p" % [ object_classes.length, object_classes ]
905
+
906
+ # Edit the entry
907
+ if newhash = edit_in_yaml( branch, object_classes )
908
+ branch.create( newhash )
909
+ message "Saved #{branch.dn}."
910
+ else
911
+ error_message "#{branch.dn} not saved."
912
+ end
913
+ end
914
+
915
+
916
+ ### Dump the specified +object+ to a file as YAML, invoke an editor on it, then undump the
917
+ ### result. If the file has changed, return the updated object, else returns +nil+.
918
+ def edit_in_yaml( object, object_classes=[] )
919
+ yaml = branch_as_yaml( object, false, object_classes )
920
+ filename = Digest::SHA1.hexdigest( yaml )
921
+ tempfile = Tempfile.new( filename )
922
+
923
+ self.log.debug "Object as YAML is: %p" % [ yaml ]
924
+ tempfile.print( yaml )
925
+ tempfile.close
926
+
927
+ new_yaml = edit( tempfile.path )
928
+
929
+ if new_yaml == yaml
930
+ message "Unchanged."
931
+ return nil
932
+ else
933
+ return YAML.load( new_yaml )
934
+ end
935
+ end
936
+
937
+
938
+ ### Return the specified Treequel::Branch object as YAML. If +include_operational+ is true,
939
+ ### include the entry's operational attributes. If +extra_objectclasses+ contains
940
+ ### one or more objectClass OIDs, include their MUST and MAY attributes when building the
941
+ ### YAML representation of the branch.
942
+ def branch_as_yaml( object, include_operational=false, extra_objectclasses=[] )
943
+ object.include_operational_attrs = include_operational
944
+
945
+ # Make sure the displayed entry has the MUST attributes
946
+ entryhash = stringify_keys( object.must_attributes_hash(*extra_objectclasses) )
947
+ entryhash.merge!( object.entry || {} )
948
+ entryhash.merge!( object.rdn_attributes )
949
+ entryhash['objectClass'] ||= []
950
+ entryhash['objectClass'] |= extra_objectclasses
951
+
952
+ entryhash.delete( 'dn' ) # Special attribute, can't be edited
953
+
954
+ yaml = entryhash.to_yaml
955
+ yaml[ 5, 0 ] = "# #{object.dn}\n"
956
+
957
+ # Make comments out of MAY attributes that are unset
958
+ mayhash = stringify_keys( object.may_attributes_hash(*extra_objectclasses) )
959
+ self.log.debug "MAY hash is: %p" % [ mayhash ]
960
+ mayhash.delete_if {|attrname,val| entryhash.key?(attrname) }
961
+ yaml << mayhash.to_yaml[5..-1].gsub( /\n\n/, "\n" ).gsub( /^/, '# ' )
962
+
963
+ return yaml
964
+ end
965
+
966
+
967
+ ### Create a command table that maps command abbreviations to the Method object that
968
+ ### implements it.
969
+ def make_command_table( commands )
970
+ table = commands.abbrev
971
+ table.keys.each do |abbrev|
972
+ mname = table.delete( abbrev )
973
+ table[ abbrev ] = self.method( mname + '_command' )
974
+ end
975
+
976
+ return table
977
+ end
978
+
979
+
980
+ ### Output a header containing the given +text+.
981
+ def header( text )
982
+ header = colorize( text, :underscore, :cyan )
983
+ $stderr.puts( header )
984
+ end
985
+
986
+
987
+ ### Output the specified message +parts+.
988
+ def message( *parts )
989
+ $stderr.puts( *parts )
990
+ end
991
+
992
+
993
+ ### Output the specified <tt>msg</tt> as an ANSI-colored error message
994
+ ### (white on red).
995
+ def error_message( msg, details='' )
996
+ $stderr.puts colorize( 'bold', 'white', 'on_red' ) { msg } + ' ' + details
997
+ end
998
+ alias :error :error_message
999
+
1000
+
1001
+ ### Highlight and embed a prompt control character in the given +string+ and return it.
1002
+ def make_prompt_string( string )
1003
+ return CLEAR_CURRENT_LINE + colorize( 'bold', 'yellow' ) { string + ' ' }
1004
+ end
1005
+
1006
+
1007
+ ### Output the specified <tt>prompt_string</tt> as a prompt (in green) and
1008
+ ### return the user's input with leading and trailing spaces removed. If a
1009
+ ### test is provided, the prompt will repeat until the test returns true.
1010
+ ### An optional failure message can also be passed in.
1011
+ def prompt( prompt_string, failure_msg="Try again." ) # :yields: response
1012
+ prompt_string.chomp!
1013
+ prompt_string << ":" unless /\W$/.match( prompt_string )
1014
+ response = nil
1015
+
1016
+ begin
1017
+ prompt = make_prompt_string( prompt_string )
1018
+ response = readline( prompt ) || ''
1019
+ response.strip!
1020
+ if block_given? && ! yield( response )
1021
+ error_message( failure_msg + "\n\n" )
1022
+ response = nil
1023
+ end
1024
+ end while response.nil?
1025
+
1026
+ return response
1027
+ end
1028
+
1029
+
1030
+ ### Prompt the user with the given <tt>prompt_string</tt> via #prompt,
1031
+ ### substituting the given <tt>default</tt> if the user doesn't input
1032
+ ### anything. If a test is provided, the prompt will repeat until the test
1033
+ ### returns true. An optional failure message can also be passed in.
1034
+ def prompt_with_default( prompt_string, default, failure_msg="Try again." )
1035
+ response = nil
1036
+
1037
+ begin
1038
+ default ||= '~'
1039
+ response = prompt( "%s [%s]" % [ prompt_string, default ] )
1040
+ response = default.to_s if !response.nil? && response.empty?
1041
+
1042
+ self.log.debug "Validating response %p" % [ response ]
1043
+
1044
+ # the block is a validator. We need to make sure that the user didn't
1045
+ # enter '~', because if they did, it's nil and we should move on. If
1046
+ # they didn't, then call the block.
1047
+ if block_given? && response != '~' && ! yield( response )
1048
+ error_message( failure_msg + "\n\n" )
1049
+ response = nil
1050
+ end
1051
+ end while response.nil?
1052
+
1053
+ return nil if response == '~'
1054
+ return response
1055
+ end
1056
+
1057
+
1058
+ ### Prompt for an array of values
1059
+ def prompt_for_multiple_values( label, default=nil, completions=[] )
1060
+ old_completion_proc = nil
1061
+
1062
+ message( MULTILINE_PROMPT % [label] )
1063
+ if default
1064
+ message "Enter a single blank line to keep the default:\n %p" % [ default ]
1065
+ end
1066
+
1067
+ results = []
1068
+ result = nil
1069
+
1070
+ if !completions.empty?
1071
+ self.log.debug "Prompting with %d completions." % [ completions.length ]
1072
+ old_completion_proc = Readline.completion_proc
1073
+ Readline.completion_proc = Proc.new do |input|
1074
+ completions.flatten.grep( /^#{Regexp.quote(input)}/i ).sort
1075
+ end
1076
+ end
1077
+
1078
+ begin
1079
+ result = readline( make_prompt_string("> ") )
1080
+ if result.nil? || result.empty?
1081
+ results << default if default && results.empty?
1082
+ else
1083
+ results << result
1084
+ end
1085
+ end until result.nil? || result.empty?
1086
+
1087
+ return results.flatten
1088
+ ensure
1089
+ Readline.completion_proc = old_completion_proc if old_completion_proc
1090
+ end
1091
+
1092
+
1093
+ ### Turn echo and masking of input on/off.
1094
+ def noecho( masked=false )
1095
+ rval = nil
1096
+ term = Termios.getattr( $stdin )
1097
+
1098
+ begin
1099
+ newt = term.dup
1100
+ newt.c_lflag &= ~Termios::ECHO
1101
+ newt.c_lflag &= ~Termios::ICANON if masked
1102
+
1103
+ Termios.tcsetattr( $stdin, Termios::TCSANOW, newt )
1104
+
1105
+ rval = yield
1106
+ ensure
1107
+ Termios.tcsetattr( $stdin, Termios::TCSANOW, term )
1108
+ end
1109
+
1110
+ return rval
1111
+ end
1112
+
1113
+
1114
+ ### Prompt the user for her password, turning off echo if the 'termios' module is
1115
+ ### available.
1116
+ def prompt_for_password( prompt="Password: " )
1117
+ rval = nil
1118
+ noecho( true ) do
1119
+ $stderr.print( prompt )
1120
+ rval = ($stdin.gets || '').chomp
1121
+ end
1122
+ $stderr.puts
1123
+ return rval
1124
+ end
1125
+
1126
+
1127
+ ### Display a description of a potentially-dangerous task, and prompt
1128
+ ### for confirmation. If the user answers with anything that begins
1129
+ ### with 'y', yield to the block. If +abort_on_decline+ is +true+,
1130
+ ### any non-'y' answer will fail with an error message.
1131
+ def ask_for_confirmation( description, abort_on_decline=true )
1132
+ puts description
1133
+
1134
+ answer = prompt_with_default( "Continue?", 'n' ) do |input|
1135
+ input =~ /^[yn]/i
1136
+ end
1137
+
1138
+ if answer =~ /^y/i
1139
+ return yield
1140
+ elsif abort_on_decline
1141
+ error "Aborted."
1142
+ fail
1143
+ end
1144
+
1145
+ return false
1146
+ end
1147
+ alias :prompt_for_confirmation :ask_for_confirmation
1148
+
1149
+
1150
+ ### Invoke the user's editor on the given +filename+ and return the exit code
1151
+ ### from doing so.
1152
+ def edit( filename )
1153
+ editor = ENV['EDITOR'] || ENV['VISUAL'] || DEFAULT_EDITOR
1154
+ system editor, filename.to_s
1155
+ unless $?.success? || editor =~ /vim/i
1156
+ raise "Editor exited with an error status (%d)" % [ $?.exitstatus ]
1157
+ end
1158
+ return File.read( filename )
1159
+ end
1160
+
1161
+
1162
+ ### Make an easily-comparable version vector out of +ver+ and return it.
1163
+ def vvec( ver )
1164
+ return ver.split('.').collect {|char| char.to_i }.pack('N*')
1165
+ end
1166
+
1167
+
1168
+ ### Raise a RuntimeError if the specified +rdn+ is invalid.
1169
+ def validate_rdns( *rdns )
1170
+ rdns.flatten.each do |rdn|
1171
+ raise "invalid RDN %p" % [ rdn ] unless RELATIVE_DISTINGUISHED_NAME.match( rdn )
1172
+ end
1173
+ end
1174
+
1175
+
1176
+ ### Return an ANSI-colored version of the given +rdn+ string.
1177
+ def format_rdn( rdn )
1178
+ rdn.split( /,/ ).collect do |rdn_part|
1179
+ key, val = rdn_part.split( /\s*=\s*/, 2 )
1180
+ colorize( :white ) { key } +
1181
+ colorize( :bold, :black ) { '=' } +
1182
+ colorize( :bold, :white ) { val }
1183
+ end.join( colorize(',', :green) )
1184
+ end
1185
+
1186
+
1187
+ ### Highlight LDIF and return it.
1188
+ def format_ldif( ldif )
1189
+ self.log.debug "Formatting LDIF: %p" % [ ldif ]
1190
+ return ldif.gsub( LDIF_ATTRVAL_SPEC ) do
1191
+ key, val = $1, $2.strip
1192
+ self.log.debug " formatting attribute: [ %p, %p ], remainder: %p" %
1193
+ [ key, val, $POSTMATCH ]
1194
+
1195
+ case val
1196
+
1197
+ # Base64-encoded value
1198
+ when /^:/
1199
+ val = val[1..-1].strip
1200
+ key +
1201
+ colorize( :dark, :green ) { ':: ' } +
1202
+ colorize( :green ) { val } + "\n"
1203
+
1204
+ # URL
1205
+ when /^</
1206
+ val = val[1..-1].strip
1207
+ key +
1208
+ colorize( :dark, :yellow ) { ':< ' } +
1209
+ colorize( :yellow ) { val } + "\n"
1210
+
1211
+ # Regular attribute
1212
+ else
1213
+ key +
1214
+ colorize( :dark, :white ) { ': ' } +
1215
+ colorize( :bold, :white ) { val } + "\n"
1216
+ end
1217
+ end
1218
+ end
1219
+
1220
+
1221
+ ### Return the specified +entries+ as an Array of span-sorted columns fit to the
1222
+ ### current terminal width.
1223
+ def columnize( *entries )
1224
+ return Columnize.columnize( entries.flatten, @columns, ' ' )
1225
+ end
1226
+
1227
+ end # class Treequel::Shell
1228
+
1229
+
1230
+ Treequel::Shell.run( ARGV.dup )
1231
+