divineflame-smurftp 0.5.1

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