matthewtodd-downloads 0.6.5

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,66 @@
1
+ Downloads helps me get big files into Tanzania. It's been fun to write! Maybe you'll be able to make use of it, too.
2
+
3
+ == Overview
4
+
5
+ Since http downloads can be a little flaky over a slow connection, we take this basic approach:
6
+
7
+ 1. Ssh to a server with a fast connection and download the file to a special directory there.
8
+ 2. Later, rsync down the contents of that directory.
9
+
10
+ Beyond that, it's all polish. There's a nice interactive shell, smart (non-)use of the network, and good tab completion all around.
11
+
12
+ == Sample Workflow
13
+
14
+ $ downloads add http://media.railscasts.com/videos/143_paypal_security.mov
15
+ $ downloads sync
16
+ receiving incremental file list
17
+ 143_paypal_security.mov
18
+ 229376 0% 7.86kB/s 0:50:27
19
+ ...
20
+ $ downloads rm 143_paypal_security.mov
21
+ $
22
+
23
+ == Installation
24
+
25
+ sudo gem install matthewtodd-downloads --source http://gems.github.com
26
+
27
+ == Configuration
28
+
29
+ You can see your current configuration via the "config" command:
30
+
31
+ downloads config
32
+
33
+ To update these parameters, pass the key and the new value like so:
34
+
35
+ downloads config remote_host downloads.example.com
36
+
37
+ == Commands
38
+
39
+ $ downloads help
40
+ add URL ...
41
+ attachments
42
+ config [KEY [VALUE]]
43
+ help [COMMAND]
44
+ ls
45
+ mv SOURCE TARGET
46
+ quit
47
+ rm FILE ...
48
+ shell
49
+ status
50
+ sync [kill]
51
+
52
+ == Tips
53
+
54
+ 1. <tt>downloads shell</tt> re-uses the same ssh connection through your entire session, <b>saving the re-connection overhead</b> for every remote command.
55
+
56
+ 2. <b>Tab completion</b> is available for free in <tt>downloads shell</tt>. You can also get it at the command line thanks to Dr. Nic's excellent TabTab gem, http://github.com/drnic/tabtab/tree/REL-0.9.1/PostInstall.txt.
57
+
58
+ 3. I use the following <b>cron jobs</b> to <b>sync my downloads overnight</b>. (Restarting <tt>sync</tt> every half hour helps recover from dropped connections.)
59
+ */30 0-5,22-23 * * * downloads sync
60
+ 30 6 * * * downloads sync kill
61
+
62
+ 4. I like to set up a <b>GeekTool window</b> showing the results of <tt>downloads status</tt>. If you like, you can bottom-justify this text by piping it through http://gist.github.com/43363.
63
+
64
+ 5. Install Downloads on your mail server, and you can set up a special email address, say <tt>big.files@downloads.example.com</tt>, that pipes all its messages through <tt>downloads attachments</tt>. Currently, the messages will be dropped on the floor, but <b>any attachments will be dropped into your downloads folder</b>, ready to be sync'ed down.
65
+
66
+ 6. I've been noodling with some <b>applescripts</b>. Check out the <tt>resources</tt> folder.
File without changes
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby -w
2
+
3
+ $:.unshift File.join(File.dirname(__FILE__), '..', 'lib')
4
+ require 'downloads'
5
+
6
+ command = Downloads::Commands.lookup(ARGV)
7
+ if command.valid?
8
+ command.run
9
+ else
10
+ puts "Usage: downloads #{command.usage}"
11
+ exit 1
12
+ end
@@ -0,0 +1,3 @@
1
+ require 'downloads/commands'
2
+ require 'downloads/configuration'
3
+ require 'downloads/servers'
@@ -0,0 +1,81 @@
1
+ module Downloads #:nodoc:
2
+ module Commands
3
+ def self.configuration
4
+ @@configuration ||= Configuration.new
5
+ end
6
+
7
+ def self.registry
8
+ @@registry ||= {}
9
+ end
10
+
11
+ def self.names
12
+ registry.keys.sort
13
+ end
14
+
15
+ def self.objects
16
+ registry.values.sort_by { |command| command.command_name }
17
+ end
18
+
19
+ # MAYBE we could use optparse at this top level as well, allowing for (1) overriding host & directory config and (2) faking remote interactions
20
+ def self.lookup(argv)
21
+ klass = registry[argv.shift] || Help
22
+ klass.new(configuration, argv)
23
+ end
24
+
25
+ class Base
26
+ def self.command_name
27
+ name.split('::').last.downcase
28
+ end
29
+
30
+ def self.usage
31
+ command_name
32
+ end
33
+
34
+ def self.inherited(command)
35
+ Commands.registry[command.command_name] = command
36
+ end
37
+
38
+ attr_reader :configuration, :local, :remote, :options
39
+
40
+ def initialize(configuration, argv)
41
+ @configuration = configuration
42
+ @local = configuration.local_server
43
+ @remote = configuration.remote_server
44
+ configure(argv)
45
+ end
46
+
47
+ def configure(argv)
48
+ end
49
+
50
+ def usage
51
+ self.class.usage
52
+ end
53
+
54
+ def run
55
+ raise NotImplementedError
56
+ end
57
+
58
+ def valid?
59
+ true
60
+ end
61
+
62
+ private
63
+
64
+ def shift_argument(argv)
65
+ argv.shift unless argv.first =~ /^-/
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ require 'downloads/commands/add'
72
+ require 'downloads/commands/attachments'
73
+ require 'downloads/commands/config'
74
+ require 'downloads/commands/help'
75
+ require 'downloads/commands/ls'
76
+ require 'downloads/commands/mv'
77
+ require 'downloads/commands/quit'
78
+ require 'downloads/commands/rm'
79
+ require 'downloads/commands/shell'
80
+ require 'downloads/commands/status'
81
+ require 'downloads/commands/sync'
@@ -0,0 +1,36 @@
1
+ require 'uri'
2
+
3
+ module Downloads
4
+ module Commands
5
+ class Add < Base
6
+ attr_accessor :uris
7
+
8
+ def self.usage
9
+ "#{super} URL ..."
10
+ end
11
+
12
+ def configure(argv)
13
+ self.uris = []
14
+ while uri = shift_argument(argv)
15
+ self.uris << parse_uri(uri)
16
+ end
17
+ end
18
+
19
+ def run
20
+ remote.run("wget '#{uris.join("' '")}' --no-check-certificate")
21
+ end
22
+
23
+ def valid?
24
+ uris.any?
25
+ end
26
+
27
+ private
28
+
29
+ def parse_uri(uri)
30
+ URI.parse(uri)
31
+ rescue URI::InvalidURIError => error
32
+ puts error.message
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,23 @@
1
+ require 'rubygems'
2
+ require 'tmail'
3
+
4
+ module Downloads
5
+ module Commands
6
+ class Attachments < Base
7
+ attr_accessor :stream
8
+
9
+ def configure(argv)
10
+ self.stream = ARGF
11
+ end
12
+
13
+ # TODO forward original messages from attachment emails
14
+ def run
15
+ TMail::Mail.parse(stream.read).attachments.each do |attachment|
16
+ filename = File.join(local.directory, attachment.original_filename)
17
+ File.open(filename, 'wb') { |file| file.write(attachment.read) }
18
+ File.chmod(0644, filename)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,26 @@
1
+ module Downloads
2
+ module Commands
3
+ class Config < Base
4
+ attr_accessor :key, :value
5
+
6
+ def self.usage
7
+ "#{super} [KEY [VALUE]]"
8
+ end
9
+
10
+ def configure(argv)
11
+ self.key = shift_argument(argv)
12
+ self.value = shift_argument(argv)
13
+ end
14
+
15
+ def run
16
+ if value
17
+ configuration[key] = value
18
+ elsif key
19
+ puts configuration[key]
20
+ else
21
+ puts configuration.to_yaml
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,23 @@
1
+ module Downloads
2
+ module Commands
3
+ class Help < Base
4
+ attr_accessor :command
5
+
6
+ def self.usage
7
+ "#{super} [COMMAND]"
8
+ end
9
+
10
+ def configure(argv)
11
+ self.command = shift_argument(argv)
12
+ end
13
+
14
+ def run
15
+ if command
16
+ puts Commands.registry[command].usage
17
+ else
18
+ puts Commands.objects.map { |command| command.usage }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,9 @@
1
+ module Downloads
2
+ module Commands
3
+ class Ls < Base
4
+ def run
5
+ puts remote.filenames
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,25 @@
1
+ module Downloads
2
+ module Commands
3
+ class Mv < Base
4
+ attr_accessor :source, :target
5
+
6
+ def self.usage
7
+ "#{super} SOURCE TARGET"
8
+ end
9
+
10
+ def configure(argv)
11
+ self.source = shift_argument(argv)
12
+ self.target = shift_argument(argv)
13
+ end
14
+
15
+ def run
16
+ remote.run("mv '#{source}' '#{target}'")
17
+ local.run("mv '#{source}' '#{target}'") if local.exists?(source)
18
+ end
19
+
20
+ def valid?
21
+ source && target
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,10 @@
1
+ module Downloads
2
+ module Commands
3
+ class Quit < Base
4
+ def run
5
+ puts 'Goodbye.'
6
+ exit
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,26 @@
1
+ module Downloads
2
+ module Commands
3
+ class Rm < Base
4
+ attr_accessor :filenames
5
+
6
+ def self.usage
7
+ "#{super} FILE ..."
8
+ end
9
+
10
+ def configure(argv)
11
+ self.filenames = []
12
+ while filename = shift_argument(argv)
13
+ self.filenames << filename
14
+ end
15
+ end
16
+
17
+ def run
18
+ remote.run("rm '#{filenames.join("' '")}'")
19
+ end
20
+
21
+ def valid?
22
+ filenames.any?
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,36 @@
1
+ require 'tabtab' unless Object.const_defined?(:TabTab)
2
+ require File.join(File.dirname(__FILE__), '..', 'tabtab_definitions')
3
+ require 'readline'
4
+ require 'shellwords'
5
+
6
+ module Downloads
7
+ module Commands
8
+ class Shell < Base
9
+ def run
10
+ Readline.completer_word_break_characters = ''
11
+ Readline.completion_append_character = ' '
12
+ Readline.completion_proc = lambda do |line|
13
+ words = Shellwords.shellwords("downloads #{line}")
14
+ words << '' if line.split('')[-1] == ' '
15
+ previous_token, current_token = words[-2..-1]
16
+ completions = TabTab::Definition['downloads'].extract_completions(previous_token, current_token, {})
17
+ completions.map { |completion| (words[1..-2] + [completion]).join(' ') }
18
+ end
19
+
20
+ puts 'Type ctrl-d or quit to exit.'
21
+
22
+ loop do
23
+ line = Readline::readline('> ') || 'quit'
24
+ next if line.strip == ''
25
+ Readline::HISTORY.push(line)
26
+ command = Downloads::Commands.lookup(line.split(' '))
27
+ if command.valid?
28
+ command.run
29
+ else
30
+ puts command.usage
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,40 @@
1
+ module Downloads
2
+ module Commands
3
+ class Status < Base
4
+ def run
5
+ longest_filename = remote.filenames.max { |a, b| a.length <=> b.length }
6
+ remote.files.each do |file|
7
+ puts "%-#{longest_filename.length}s\t%3s%%\t%5s" % [file[:name], status(file), human_readable(file[:size])]
8
+ end
9
+ end
10
+
11
+ private
12
+
13
+ def status(remote_file)
14
+ local_file = local.exists?(remote_file[:name]) || { :size => 0 }
15
+ percent(local_file[:size], remote_file[:size])
16
+ end
17
+
18
+ def percent(numerator, denominator)
19
+ if denominator.zero? # oddly enough, this is happening for a .webloc file on my Desktop right now
20
+ '-'
21
+ else
22
+ (numerator.to_f * 100 / denominator).to_i
23
+ end
24
+ end
25
+
26
+ def human_readable(bytes)
27
+ case bytes
28
+ when (0...1024)
29
+ "#{bytes}B"
30
+ when (1024...1024**2)
31
+ "#{bytes/1024}K"
32
+ when (1024**2...1024**3)
33
+ "#{bytes/1024**2}M"
34
+ else
35
+ "#{bytes/1024**3}G"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,40 @@
1
+ module Downloads
2
+ module Commands
3
+ class Sync < Base
4
+ attr_accessor :kill
5
+
6
+ def self.usage
7
+ "#{super} [kill]"
8
+ end
9
+
10
+ def configure(argv)
11
+ self.kill = (shift_argument(argv) == 'kill')
12
+ end
13
+
14
+ def run
15
+ if File.exists?(pid_file)
16
+ `kill #{File.read(pid_file)}`
17
+ File.delete(pid_file)
18
+ end
19
+
20
+ unless kill
21
+ pid = fork { exec("rsync --recursive --progress --partial #{remote.rsync_path} #{local.rsync_path}") }
22
+ File.open(pid_file, 'w') { |file| file.write(pid) }
23
+
24
+ begin
25
+ Process.wait
26
+ rescue Interrupt
27
+ # we don't need to see the stacktrace
28
+ puts # but a blank line is nice
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def pid_file
36
+ configuration.pid_file
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,61 @@
1
+ require 'pathname'
2
+ require 'yaml'
3
+
4
+ module Downloads
5
+ class Configuration
6
+ DIRECTORY = Pathname.new(File.join(ENV['HOME'], '.downloads'))
7
+ DEFAULTS = { 'remote_host' => 'downloads', 'remote_directory' => 'downloads', 'local_directory' => File.join(ENV['HOME'], 'Desktop') }.freeze
8
+
9
+ attr_reader :local_server, :remote_server
10
+
11
+ def initialize
12
+ DIRECTORY.mkdir unless DIRECTORY.directory?
13
+
14
+ @values = DEFAULTS.dup
15
+ @values.merge!(YAML.load_file(path('config'))) if path('config').file?
16
+
17
+ @local_server = build_local_server
18
+ @remote_server = build_remote_server
19
+ end
20
+
21
+ def [](key)
22
+ @values[key.to_s]
23
+ end
24
+
25
+ def []=(key, value)
26
+ @values[key.to_s] = value
27
+ File.open(path('config'), 'w') { |file| file.puts(to_yaml) }
28
+
29
+ case key.to_s
30
+ when /^remote/
31
+ File.delete(path('remote_cache')) if path('remote_cache').file?
32
+ @remote_server = build_remote_server
33
+ when /^local/
34
+ @local_server = build_local_server
35
+ end
36
+ end
37
+
38
+ def pid_file
39
+ path('pid')
40
+ end
41
+
42
+ def to_yaml
43
+ # Marginally nicer than @values.to_yaml, as it avoids the leading '---'
44
+ @values.map { |key, value| "#{key}: #{value}" }.join("\n")
45
+ end
46
+
47
+ private
48
+
49
+ def build_local_server
50
+ Servers::Local.new(@values['local_directory'])
51
+ end
52
+
53
+ def build_remote_server
54
+ Servers::Remote.new(@values['remote_host'], @values['remote_directory'], path('remote_cache'))
55
+ end
56
+
57
+ def path(path)
58
+ DIRECTORY + path
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,28 @@
1
+ module Downloads
2
+ module Servers #:nodoc:
3
+ class Base
4
+ def exists?(filename)
5
+ filenames.include?(filename)
6
+ end
7
+
8
+ def files
9
+ raise NotImplementedError
10
+ end
11
+
12
+ def filenames
13
+ files.map { |file| file[:name] }
14
+ end
15
+
16
+ def rsync_path
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def run(command)
21
+ raise NotImplementedError
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ require 'downloads/servers/local'
28
+ require 'downloads/servers/remote'
@@ -0,0 +1,25 @@
1
+ module Downloads
2
+ module Servers
3
+ class Local < Base
4
+ attr_reader :directory
5
+
6
+ def initialize(directory)
7
+ @directory = directory
8
+ end
9
+
10
+ def files
11
+ Dir.chdir(directory) do
12
+ Dir.glob('*').sort.map { |name| { :name => name, :size => File.size(name) } }
13
+ end
14
+ end
15
+
16
+ def rsync_path
17
+ "#{@directory}/"
18
+ end
19
+
20
+ def run(command)
21
+ `sh -c "cd #{@directory}; #{command}"`
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,52 @@
1
+ require 'net/ssh'
2
+
3
+ module Downloads
4
+ module Servers
5
+ class Remote < Base
6
+ def initialize(host, directory, cache_path)
7
+ @host = host
8
+ @directory = directory
9
+ @cache_path = cache_path
10
+ @connection = nil
11
+ @files = YAML.load_file(@cache_path) if File.exists?(@cache_path)
12
+ end
13
+
14
+ def files
15
+ populate_file_cache unless @files
16
+ @files
17
+ end
18
+
19
+ def rsync_path
20
+ "#{@host}:#{@directory}/"
21
+ end
22
+
23
+ def run(command)
24
+ result = run_in_directory(command)
25
+ populate_file_cache
26
+ result
27
+ end
28
+
29
+ private
30
+
31
+ def connection
32
+ initialize_connection unless @connection
33
+ @connection
34
+ end
35
+
36
+ def initialize_connection
37
+ @connection = Net::SSH.start(@host, ENV['USER']) # TODO does it make sense to use ENV['USER']?
38
+ at_exit { @connection.close }
39
+ end
40
+
41
+ def run_in_directory(command)
42
+ connection.exec!("cd #{@directory}; #{command}")
43
+ end
44
+
45
+ def populate_file_cache
46
+ yaml = run_in_directory(%{ruby -ryaml -e "puts Dir.glob('*').sort.map { |name| { :name => name, :size => File.size(name) } }.to_yaml"})
47
+ File.open(@cache_path, 'w') { |file| file.write(yaml) }
48
+ @files = YAML.load(yaml)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,55 @@
1
+ TabTab::Definition.register('downloads') do |c|
2
+ def clipboard_contents
3
+ [`pbpaste`.strip]
4
+ end
5
+
6
+ def commands
7
+ require File.join(File.dirname(__FILE__), 'commands') unless Object.const_defined?(:Downloads)
8
+ Downloads::Commands.names
9
+ end
10
+
11
+ def remote_files
12
+ require File.join(File.dirname(__FILE__), 'commands') unless Object.const_defined?(:Downloads)
13
+ Downloads::Commands.configuration.remote_server.filenames
14
+ end
15
+
16
+ # FIXME waiting for tabtab to support completion for multiple filenames
17
+ c.command(:add) do |add|
18
+ add.default { clipboard_contents.grep(/^http:/) }
19
+ end
20
+
21
+ c.command(:config) do |config|
22
+ config.command(:remote_host) { }
23
+ config.command(:remote_directory) { }
24
+ config.command(:local_directory) { }
25
+ end
26
+
27
+ c.command(:help) do |help|
28
+ help.default { commands }
29
+ end
30
+
31
+ c.command(:ls) do |ls|
32
+ end
33
+
34
+ c.command(:mv) do |mv|
35
+ mv.default { remote_files }
36
+ end
37
+
38
+ # FIXME waiting for tabtab to support completion for multiple filenames
39
+ c.command(:rm) do |rm|
40
+ rm.default { remote_files }
41
+ end
42
+
43
+ c.command(:quit) do |shell|
44
+ end
45
+
46
+ c.command(:shell) do |shell|
47
+ end
48
+
49
+ c.command(:status) do |status|
50
+ end
51
+
52
+ c.command(:sync) do |sync|
53
+ sync.command :kill
54
+ end
55
+ end
metadata ADDED
@@ -0,0 +1,110 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: matthewtodd-downloads
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.6.5
5
+ platform: ruby
6
+ authors:
7
+ - Matthew Todd
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-01-06 00:00:00 -08:00
13
+ default_executable: downloads
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: net-ssh
17
+ version_requirement:
18
+ version_requirements: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 2.0.3
23
+ version:
24
+ - !ruby/object:Gem::Dependency
25
+ name: tabtab
26
+ version_requirement:
27
+ version_requirements: !ruby/object:Gem::Requirement
28
+ requirements:
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 0.9.1
32
+ version:
33
+ - !ruby/object:Gem::Dependency
34
+ name: tmail
35
+ version_requirement:
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 1.2.2
41
+ version:
42
+ description:
43
+ email: matthew.todd@gmail.com
44
+ executables:
45
+ - downloads
46
+ extensions: []
47
+
48
+ extra_rdoc_files:
49
+ - README.rdoc
50
+ - TODO.rdoc
51
+ - bin/downloads
52
+ files:
53
+ - README.rdoc
54
+ - TODO.rdoc
55
+ - bin/downloads
56
+ - lib/downloads/commands/add.rb
57
+ - lib/downloads/commands/attachments.rb
58
+ - lib/downloads/commands/config.rb
59
+ - lib/downloads/commands/help.rb
60
+ - lib/downloads/commands/ls.rb
61
+ - lib/downloads/commands/mv.rb
62
+ - lib/downloads/commands/quit.rb
63
+ - lib/downloads/commands/rm.rb
64
+ - lib/downloads/commands/shell.rb
65
+ - lib/downloads/commands/status.rb
66
+ - lib/downloads/commands/sync.rb
67
+ - lib/downloads/commands.rb
68
+ - lib/downloads/configuration.rb
69
+ - lib/downloads/servers/local.rb
70
+ - lib/downloads/servers/remote.rb
71
+ - lib/downloads/servers.rb
72
+ - lib/downloads/tabtab_definitions.rb
73
+ - lib/downloads.rb
74
+ - resources/applescripts
75
+ - resources/applescripts/Add Downloads.app
76
+ - resources/applescripts/folderaction_download_webloc.scpt
77
+ - resources/applescripts/netnewswire_download_enclosure.scpt
78
+ has_rdoc: true
79
+ homepage:
80
+ post_install_message:
81
+ rdoc_options:
82
+ - --main
83
+ - README.rdoc
84
+ - --title
85
+ - downloads-0.6.5
86
+ - --inline-source
87
+ - --line-numbers
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: "0"
95
+ version:
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: "0"
101
+ version:
102
+ requirements:
103
+ - rsync
104
+ rubyforge_project:
105
+ rubygems_version: 1.2.0
106
+ signing_key:
107
+ specification_version: 2
108
+ summary: Downloads uses net-ssh, rsync and tmail to reliably get big files into Tanzania.
109
+ test_files: []
110
+