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