gruner-smurftp 0.5.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.
@@ -0,0 +1,59 @@
1
+ # Smurftp
2
+
3
+ Smurftp is a command-line utility written in Ruby that searches a specified directory and creates a queue of recently modified files for quickly uploading to a remote server over FTP.
4
+
5
+ It was written for making web site edits where you might have a small group of files in multiple subdirectories that you want uploaded without having to manually go directory by directory.
6
+
7
+ ## Install
8
+
9
+ $ gem sources -a http://gems.github.com
10
+ $ sudo gem install divineflame-smurftp
11
+
12
+ ## Usage
13
+
14
+ Start Smurftp with this command:
15
+
16
+ $ smurftp <directory or configuration file>
17
+
18
+ ## Configuration
19
+
20
+ Smurftp requires a configuration file in YAML format that defines your FTP server and login information, as well as a local directory ('document_root').
21
+
22
+ When starting Smurftp, if you specify a directory, it looks for a 'smurftp_config.yaml' file in that directory. If the file is not found you'll be given the option for this file to be generated for you.
23
+
24
+ Alternatively, if you specify an existing configuration file (it doesn't require the 'smurftp_config.yaml' name) Smurftp will start by loading this file. Because the 'document_root' setting is defined in the configuration file, you can store the configuration file separately from your project files if you choose.
25
+
26
+ It's also possible to define multiple servers, sites, and login credentials in the same configuration file.
27
+
28
+ ## File List
29
+
30
+ Smurftp will search the defined 'document_root' directory and list all files in all sub directories, ordered by modification date with newest files first. It skips any files defined in the configuration's 'exclusions' list.
31
+
32
+ ### Sample Output
33
+
34
+ [1] foo.php
35
+ [2] images/bar.jpg
36
+ [3] includes/header.php
37
+ [4] images/logo.png
38
+ [5] yada.php
39
+ ====================
40
+ smurftp>
41
+
42
+ At the `smurftp>` prompt enter the number identifier of the files you want uploaded. You can also enter a list or range of files.
43
+
44
+ ### Example Commands
45
+
46
+ '1' uploads file [1]
47
+ '1-5' uploads files [1-5]
48
+ '1,2,4' uploads [1], [2], and [4]
49
+ '1-5,^4' uploads files [1-3], and [5], skipping file [4]
50
+ '1-5,!4' same as above
51
+ 'all' uploads all listed files
52
+
53
+ To quit type `quit` or `exit` at the prompt. `e` or `q` will also quit.
54
+
55
+ ## TODO
56
+
57
+ * support relative paths for :document_root
58
+ * add optional sftp
59
+ * automatically refresh file list
@@ -0,0 +1,4 @@
1
+ ---
2
+ :major: 0
3
+ :minor: 5
4
+ :patch: 1
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require File.dirname(__FILE__) + '/../lib/smurftp'
4
+
5
+ input = ARGV[0]
6
+ site = ARGV[1]
7
+
8
+ if !input
9
+ puts "Please specify a directory or configuration file to run smurftp from"
10
+ exit
11
+ end
12
+
13
+ config_file = ''
14
+
15
+ if File.directory?(input)
16
+ directory = input
17
+ config_file = "#{input}/smurftp_config.yaml"
18
+ unless File.file?(config_file)
19
+ Smurftp::Configuration.generate_config_file(directory)
20
+ exit
21
+ end
22
+ elsif File.file?(input)
23
+ config_file = input
24
+ else
25
+ puts "Invalid directory or configuration file."
26
+ exit
27
+ end
28
+
29
+ smurftp = Smurftp::Shell.new(config_file, site)
30
+ smurftp.run()
@@ -0,0 +1,35 @@
1
+ require 'yaml'
2
+ require 'find'
3
+ require 'fileutils'
4
+ require 'net/ftp'
5
+ require 'readline'
6
+ # require 'rubygems'
7
+ # other dependencies
8
+
9
+ # a bit of monkey patching
10
+
11
+ class String
12
+ def to_regex!
13
+ return /#{self}/
14
+ end
15
+ end
16
+
17
+
18
+ class Hash
19
+ # Destructively convert all keys to symbols.
20
+ def symbolize_keys!
21
+ self.each do |key, value|
22
+ unless key.is_a?(Symbol)
23
+ self[key.to_sym] = self[key]
24
+ delete(key)
25
+ end
26
+ # recursively call this method on nested hashes
27
+ value.symbolize_keys! if value.class == Hash
28
+ end
29
+ self
30
+ end
31
+ end
32
+
33
+ %w(version configuration shell).each do |file|
34
+ require File.join(File.dirname(__FILE__), 'smurftp', file)
35
+ end
@@ -0,0 +1,45 @@
1
+ module Smurftp
2
+ class Configuration < Hash
3
+
4
+ def self.generate_config_file(dir)
5
+ # TODO ask before creating new file
6
+ # TODO fill out the config file by promption user for the info
7
+ templates_dir = File.dirname(__FILE__) + '/templates'
8
+ FileUtils.cp("#{templates_dir}/smurftp_config.yaml", "#{dir}/smurftp_config.yaml")
9
+ puts "No configuration file found. Creating new file."
10
+ puts "New configuration file created in #{dir}."
11
+ puts "Enter server and login info in this file and restart smurftp."
12
+ end
13
+
14
+
15
+ def initialize(file, site=nil)
16
+ load_config_file(file)
17
+ if site # merge config settings with current site
18
+ tmp = self[site]
19
+ self.clear.merge! tmp
20
+ end
21
+ self.symbolize_keys!
22
+ validate
23
+ self[:exclusions] << file #exclude config file from upload if it's in the @base_dir
24
+ self[:queue_limit] ||= 15
25
+ end
26
+
27
+
28
+ def load_config_file(file)
29
+ self.merge! YAML::load(File.open(file))
30
+ end
31
+
32
+
33
+ def validate
34
+ %w[server server_root document_root login password].each do |setting|
35
+ unless self[setting.to_sym]
36
+ raise StandardError, "Error: \"#{setting}\" is missing from configuration file."
37
+ end
38
+ end
39
+ unless File.directory?(self[:document_root])
40
+ raise StandardError, "Error: \"#{self[:document_root]}\" specified in configuration file is not a valid directory."
41
+ end
42
+ end
43
+
44
+ end
45
+ end
@@ -0,0 +1,268 @@
1
+ # Smurftp::Shell is used to create an interactive shell. It is invoked by the smurftp binary.
2
+ module Smurftp
3
+ class Shell
4
+
5
+ attr_reader :upload_queue, :file_list
6
+
7
+ def initialize(config_file, site=nil)
8
+ #Readline.basic_word_break_characters = ""
9
+ #Readline.completion_append_character = nil
10
+ @configuration = Smurftp::Configuration.new(config_file, site)
11
+ @base_dir = @configuration[:document_root]
12
+ @file_list = []
13
+ @upload_queue = []
14
+ @last_upload = nil
15
+ end
16
+
17
+
18
+ # Run a single command.
19
+ def parse_command(cmd)
20
+ case cmd.downcase
21
+ when /^(a|all)$/
22
+ return lambda { upload_all }
23
+ when /\d+,/ # digit comma
24
+ files = parse_list(cmd)
25
+ command = lambda { upload }
26
+ return command, files
27
+ when /\d+(\.+|-)\d+/
28
+ files = parse_range(cmd)
29
+ command = lambda { upload }
30
+ return command, files
31
+ when /^\d+/
32
+ file = [cmd]
33
+ command = lambda { upload }
34
+ return command, file
35
+ # when /^(m|more)/: list_more_queued_files
36
+ when /^(r|refresh|l|ls|list)$/
37
+ command = lambda do
38
+ find_files
39
+ refresh_file_display
40
+ end
41
+ return command
42
+ else
43
+ return 'error'
44
+ # TODO needs error message as fallback
45
+ end
46
+ end
47
+
48
+
49
+ # Run the interactive shell using readline.
50
+ def run
51
+ find_files
52
+ refresh_file_display
53
+ loop do
54
+ cmd = Readline.readline('smurftp> ')
55
+ finish if cmd.nil? or cmd =~ /^(e|exit|q|quit)$/
56
+ next if cmd == ""
57
+ Readline::HISTORY.push(cmd)
58
+ command, files = parse_command(cmd)
59
+ if files
60
+ add_files_to_queue(files)
61
+ end
62
+ command.call
63
+ end
64
+ end
65
+
66
+
67
+ ##
68
+ # Adds single file to upload queue, ensuring
69
+ # no duplicate additions
70
+
71
+ def add_file_to_queue(str)
72
+ str.gsub!(/[^\d]/, '') #strip non-digit characters
73
+ file = str.to_i-1
74
+ @upload_queue << file unless @upload_queue.include?(file)
75
+ end
76
+
77
+
78
+ def add_files_to_queue(files)
79
+ files.each {|f| add_file_to_queue(f)}
80
+ end
81
+
82
+
83
+ ##
84
+ # Extract a list of comma separated values from a string.
85
+ # Look for ranges and expand them, look for exceptions
86
+ # and remove them from the returned list.
87
+
88
+ def parse_list(str)
89
+ file_list = []
90
+ exceptions = []
91
+ str.split(',').each do |s|
92
+ if s =~ /-/
93
+ file_list += parse_range(s)
94
+ elsif s =~ /(\^|!)\d/
95
+ s.gsub!(/[^\d]/, '') #strip non-digit characters
96
+ exceptions << s
97
+ else
98
+ file_list << s
99
+ end
100
+ end
101
+ return file_list - exceptions
102
+ end
103
+
104
+
105
+ ##
106
+ # Extract a range of numbers from a string.
107
+ # Expand the range into an array that represents files
108
+ # in the displayed list, and returns said array.
109
+
110
+ def parse_range(str)
111
+ delimiters = str.split(/\.+|-+/)
112
+ r_start, r_end = delimiters[0], delimiters[1]
113
+ # TODO assumes even number pairs for creating a range
114
+ range = r_start.to_i..r_end.to_i
115
+ file_list = []
116
+ range.each do |n|
117
+ file_list << n.to_s
118
+ end
119
+ return file_list
120
+ end
121
+
122
+
123
+ def list_more_queued_files
124
+ # TODO
125
+ # not sure how this will work yet
126
+ end
127
+
128
+
129
+ ##
130
+ # Format the output of the file list display by looping over
131
+ # @file_list and numbering each file up to the predefined queue limit.
132
+
133
+ def refresh_file_display
134
+ if @last_upload
135
+ puts 'Files changed since last upload:'
136
+ else
137
+ puts 'Recently modified files:'
138
+ end
139
+
140
+ file_count = 1
141
+ @file_list.each do |f|
142
+ unless file_count > @configuration[:queue_limit]
143
+ spacer = ' ' unless file_count > 9 #add space to even the file numbering column
144
+ puts "#{spacer}[#{file_count}] #{f[:base_name]}"
145
+ file_count += 1
146
+ else
147
+ remaining_files = @file_list.length - file_count
148
+ puts "(plus #{remaining_files} more)"
149
+ break
150
+ end
151
+ end
152
+ puts '===================='
153
+ end
154
+
155
+
156
+ ##
157
+ # Find the files to process, ignoring temporary files, source
158
+ # configuration management files, etc., and add them to @file_list mapping
159
+ # filename to modification time.
160
+
161
+ def find_files
162
+ @file_list.clear
163
+ Find.find(@base_dir) do |f|
164
+
165
+ @configuration[:exclusions].each do |e|
166
+ Find.prune if f =~ e.to_regex!
167
+ # if e.class == Regexp
168
+ # Find.prune if f =~ e.to_regex!
169
+ # end
170
+ end
171
+
172
+ next if f =~ /(swp|~|rej|orig|bak|.git)$/ # temporary/patch files
173
+ next if f =~ /\/\.?#/ # Emacs autosave/cvs merge files
174
+ next if File.directory?(f) #skip directories
175
+
176
+ #TODO loop through exclusions that are regex objects
177
+
178
+ file_name = f.sub(/^\.\//, '')
179
+ mtime = File.stat(file_name).mtime
180
+ base_name = file_name.sub("#{@base_dir}/", '')
181
+
182
+ if @last_upload
183
+ if mtime > @last_upload
184
+ @file_list << {:name => file_name,
185
+ :base_name => base_name,
186
+ :mtime => mtime} rescue next
187
+ end
188
+ else #get all files, because we haven't uploaded yet
189
+ @file_list << {:name => file_name,
190
+ :base_name => base_name,
191
+ :mtime => mtime} rescue next
192
+ end
193
+ end
194
+ # sort list by mtime
195
+ @file_list.sort! { |x,y| y[:mtime] <=> x[:mtime] }
196
+ end
197
+
198
+
199
+ def upload
200
+ #TODO add timeout error handling
201
+ created_dirs = []
202
+ Net::FTP.open(@configuration[:server]) do |ftp|
203
+ ftp.login(@configuration[:login], @configuration[:password])
204
+ @upload_queue.each do |file_id|
205
+ file = @file_list[file_id]
206
+
207
+ dirs = parse_file_for_sub_dirs(file[:base_name])
208
+ dirs.each do |dir|
209
+ unless created_dirs.include? dir
210
+ begin
211
+ ftp.mkdir "#{@configuration[:server_root]}/#{dir}"
212
+ puts "created #{dir}..."
213
+ rescue Net::FTPPermError; end #ignore errors for existing dirs
214
+ created_dirs << dir
215
+ end
216
+ end
217
+
218
+ puts "uploading #{file[:base_name]}..."
219
+ ftp.put("#{file[:name]}", "#{@configuration[:server_root]}/#{file[:base_name]}")
220
+ # @file_list.delete_at file_id
221
+ # @upload_queue.delete file_id
222
+ end
223
+ end
224
+ @upload_queue.clear
225
+ puts "done"
226
+ @last_upload = Time.now
227
+ end
228
+
229
+
230
+ def upload_all
231
+ @file_list.length.times { |f| @upload_queue << f+1 }
232
+ upload
233
+ end
234
+
235
+
236
+ def parse_file_for_sub_dirs(file)
237
+ dirs = file.split(/\//)
238
+ return [] if dirs.length <= 1
239
+ dirs_expanded = []
240
+
241
+ while dirs.length > 1
242
+ dirs.pop
243
+ dirs_expanded << dirs.join('/')
244
+ end
245
+
246
+ return dirs_expanded.reverse
247
+ end
248
+
249
+
250
+ ##
251
+ # Close the shell and exit the program with a cheesy message.
252
+
253
+ def finish
254
+ messages =
255
+ [
256
+ 'Hasta La Vista, Baby!',
257
+ 'Peace Out, Dawg!',
258
+ 'Diggidy!',
259
+ 'Up, up, and away!',
260
+ 'Sally Forth Good Sir!'
261
+ ]
262
+ random_msg = messages[rand(messages.length)]
263
+ puts random_msg
264
+ exit
265
+ end
266
+
267
+ end
268
+ end
@@ -0,0 +1,10 @@
1
+ # smurftp configuration
2
+ server: 'ftp.yourserver.com'
3
+ server_root: 'web/'
4
+ document_root: '.'
5
+ login: 'login'
6
+ password: ''
7
+ exclusions:
8
+ - '_notes'
9
+ - 'resources'
10
+ - '/regex/'
@@ -0,0 +1,41 @@
1
+ # smurftp multisite configuration
2
+
3
+ # multiple servers can be configured
4
+ # and shared across site settings
5
+
6
+ server1: &server1
7
+ server: ftp.yourserver.com
8
+ login: your_login
9
+ password: your_password
10
+
11
+ global_exclusions: &exclusions
12
+ exclusions:
13
+ - '_notes'
14
+ - 'resources'
15
+ - '/regex/'
16
+
17
+ # define multiple sites which can
18
+ # share server info and global_exclusions
19
+ # in addition to defining their own
20
+
21
+ site1:
22
+ <<: *server1
23
+ <<: *exclusions
24
+ server_root: 'web/'
25
+ document_root: '.'
26
+
27
+ site2:
28
+ <<: *server1
29
+ exclusions:
30
+ - 'psd'
31
+ - 'src'
32
+ server_root: 'web/'
33
+ document_root: '.'
34
+
35
+ site3:
36
+ server: ftp.anotherserver.com
37
+ login: site3_login
38
+ password: site3_password
39
+ server_root: 'web/'
40
+ document_root: '.'
41
+ <<: *exclusions
@@ -0,0 +1,34 @@
1
+ module Smurftp #:nodoc:
2
+ module VERSION #:nodoc:
3
+ MAJOR = 0
4
+ MINOR = 5
5
+ TINY = 0
6
+
7
+ STRING = [MAJOR, MINOR, TINY].join('.')
8
+ URLIFIED = STRING.tr('.', '_')
9
+
10
+ # requirements_met? can take a hash with :major, :minor, :tiny set or
11
+ # a string in the format "major.minor.tiny"
12
+ def self.requirements_met?(minimum_version = {})
13
+ major = minor = tiny = 0
14
+ if minimum_version.is_a?(Hash)
15
+ major = minimum_version[:major].to_i if minimum_version.has_key?(:major)
16
+ minor = minimum_version[:minor].to_i if minimum_version.has_key?(:minor)
17
+ tiny = minimum_version[:tiny].to_i if minimum_version.has_key?(:tiny)
18
+ else
19
+ major, minor, tiny = minimum_version.to_s.split('.').collect { |v| v.to_i }
20
+ end
21
+ met = false
22
+ if Smurftp::VERSION::MAJOR > major
23
+ met = true
24
+ elsif Smurftp::VERSION::MAJOR == major
25
+ if Smurftp::VERSION::MINOR > minor
26
+ met = true
27
+ elsif Smurftp::VERSION::MINOR == minor
28
+ met = Smurftp::VERSION::TINY >= tiny
29
+ end
30
+ end
31
+ met
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,29 @@
1
+ require 'test/unit'
2
+ require File.dirname(__FILE__) + '/../lib/smurftp'
3
+
4
+ class SmurftpConfigurationTest < Test::Unit::TestCase
5
+ def setup
6
+ @config_file = File.dirname(__FILE__) + '/../lib/smurftp/templates/smurftp_config.yaml'
7
+ @multisite_config_file = File.dirname(__FILE__) + '/../lib/smurftp/templates/smurftp_multisite_config.yaml'
8
+ end
9
+
10
+
11
+ def test_hash_symbolize_keys
12
+ assert_equal({:yada => 'yada'}, {'yada' => 'yada'}.symbolize_keys!)
13
+ expected = {:yada => {:yada => {:yada => 'yada'}}}
14
+ sample = {'yada' => {'yada' => {'yada' => 'yada'}}}
15
+ assert_equal expected, sample.symbolize_keys!
16
+ end
17
+
18
+
19
+ def test_configuration
20
+ config = Smurftp::Configuration.new(@config_file)
21
+ end
22
+
23
+
24
+ def test_multisite_configuration
25
+ config = Smurftp::Configuration.new(@multisite_config_file, 'site1')
26
+ puts config.inspect
27
+ end
28
+
29
+ end
@@ -0,0 +1,91 @@
1
+ require 'test/unit'
2
+ require File.dirname(__FILE__) + '/../lib/smurftp'
3
+
4
+ class SmurftpShellTest < Test::Unit::TestCase
5
+ def setup
6
+ @config = File.dirname(__FILE__) + '/../lib/smurftp/templates/smurftp_config.yaml'
7
+ @smurftp = Smurftp::Shell.new(@config)
8
+ end
9
+
10
+
11
+ def test_should_parse_list
12
+ lists = [
13
+ ['1,2,3',['1','2','3']],
14
+ ['1-4,^3',['1','2','4']],
15
+ ['1,2,3-6,^4',['1','2','3','5','6']],
16
+ ['1,2,3-6,!4',['1','2','3','5','6']],
17
+ ['^4,1-6',['1','2','3','5','6']]
18
+ ].each do |input, expected|
19
+ assert_equal expected, @smurftp.parse_list(input)
20
+ end
21
+ end
22
+
23
+
24
+ def test_should_parse_range
25
+ assert_equal ['1','2','3','4'], @smurftp.parse_range('1-4')
26
+ end
27
+
28
+
29
+ def test_should_parse_file_for_sub_dirs
30
+ file_paths = [
31
+ ['one/two/three/file.txt',['one','one/two','one/two/three']],
32
+ ['file.txt',[]]
33
+ ].each do |file,expanded|
34
+ assert_equal expanded, @smurftp.parse_file_for_sub_dirs(file)
35
+ end
36
+ end
37
+
38
+
39
+ def test_should_add_files_to_queue
40
+ @smurftp.add_files_to_queue(['1','2','3','4'])
41
+ assert_equal [0,1,2,3], @smurftp.upload_queue
42
+ end
43
+
44
+
45
+ def test_upload_queue_should_be_unique
46
+ @smurftp.add_files_to_queue(['1','2','3','4','2','3','1','1','1'])
47
+ assert_equal [0,1,2,3], @smurftp.upload_queue
48
+ end
49
+
50
+
51
+ def test_parse_command_should_parse_input
52
+ input = @smurftp.parse_command('all')
53
+ assert_equal Proc, input.class
54
+
55
+ input = @smurftp.parse_command('a')
56
+ assert_equal Proc, input.class
57
+
58
+ input = @smurftp.parse_command('ardvark')
59
+ assert_equal 'error', input
60
+
61
+ input = @smurftp.parse_command('allin')
62
+ assert_equal 'error', input
63
+
64
+ cmd, files = @smurftp.parse_command('1')
65
+ assert_equal ['1'], files
66
+
67
+ cmd = @smurftp.parse_command(' 1 ')
68
+ assert_equal 'error', cmd
69
+
70
+ cmd, files = @smurftp.parse_command('1,2,3,4')
71
+ assert_equal ['1','2','3','4'], files
72
+
73
+ cmd, files = @smurftp.parse_command('1-4')
74
+ assert_equal ['1','2','3','4'], files
75
+
76
+ cmd, files = @smurftp.parse_command('1-4,^3')
77
+ assert_equal ['1','2','4'], files
78
+
79
+ cmd, files = @smurftp.parse_command('^3,1-4')
80
+ assert_equal ['1','2','4'], files
81
+ end
82
+
83
+
84
+ def test_hash_symbolize_keys
85
+ assert_equal({:yada => 'yada'}, {'yada' => 'yada'}.symbolize_keys!)
86
+ expected = {:yada => {:yada => {:yada => 'yada'}}}
87
+ sample = {'yada' => {'yada' => {'yada' => 'yada'}}}
88
+ assert_equal expected, sample.symbolize_keys!
89
+ end
90
+
91
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gruner-smurftp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.1
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Gruner
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-02-05 00:00:00 -08:00
13
+ default_executable: smurftp
14
+ dependencies: []
15
+
16
+ description: Smurftp is a command-line utility that searches a specified directory and creates a queue of recently modified files for quickly uploading to a remote server over FTP.
17
18
+ executables:
19
+ - smurftp
20
+ extensions: []
21
+
22
+ extra_rdoc_files: []
23
+
24
+ files:
25
+ - VERSION.yml
26
+ - README.mkdn
27
+ - bin/smurftp
28
+ - lib/smurftp
29
+ - lib/smurftp/shell.rb
30
+ - lib/smurftp/version.rb
31
+ - lib/smurftp/templates
32
+ - lib/smurftp/templates/smurftp_multisite_config.yaml
33
+ - lib/smurftp/templates/smurftp_config.yaml
34
+ - lib/smurftp/configuration.rb
35
+ - lib/smurftp.rb
36
+ - test/shell_test.rb
37
+ - test/configuration_test.rb
38
+ has_rdoc: true
39
+ homepage: http://github.com/divineflame/smurftp
40
+ post_install_message:
41
+ rdoc_options:
42
+ - --inline-source
43
+ - --charset=UTF-8
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: "0"
51
+ version:
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: "0"
57
+ version:
58
+ requirements: []
59
+
60
+ rubyforge_project:
61
+ rubygems_version: 1.2.0
62
+ signing_key:
63
+ specification_version: 2
64
+ summary: Command-line utility for uploading recently modified files to a server
65
+ test_files: []
66
+
OSZAR »