treequel-shell 1.10.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+