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.
- data.tar.gz.sig +0 -0
- data/ChangeLog +620 -0
- data/History.rdoc +7 -0
- data/Manifest.txt +8 -0
- data/README.rdoc +61 -0
- data/Rakefile +58 -0
- data/bin/treeirb +18 -0
- data/bin/treequel +1231 -0
- data/bin/treewhat +397 -0
- metadata +238 -0
- metadata.gz.sig +1 -0
data/History.rdoc
ADDED
data/Manifest.txt
ADDED
data/README.rdoc
ADDED
@@ -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
|
+
|
data/Rakefile
ADDED
@@ -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
|
+
|
data/bin/treeirb
ADDED
@@ -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
|
+
|
data/bin/treequel
ADDED
@@ -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
|
+
|