loft-harmony 1.1.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.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/bin/harmony +644 -0
  3. metadata +44 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 357be3211af77da5e3cd8a20d084446311bf1743
4
+ data.tar.gz: 526c22da20b924a191a59e51005e5eaa8ba13e36
5
+ SHA512:
6
+ metadata.gz: 63b4cba6a29257c401496a11adc42ec9e485b8e8d2db027d01b0f9415b299bf12f17509fe2518d8070b22ceb381ac6b73c9d49564449a3586669d7043904a40d
7
+ data.tar.gz: f508d0854a236ea048c6444d6fae9389a9d2bbfdd6ccc6cd80442cee14df4b43350f302e3862dc5bd845311f78dee2a8374ab53659959630348d0dccc1a2f6b1
data/bin/harmony ADDED
@@ -0,0 +1,644 @@
1
+ #!/usr/bin/env ruby
2
+ require 'net/ftp'
3
+ require 'terrun'
4
+ require 'timeout'
5
+
6
+ class Harmony < TerminalRunner
7
+ name "Harmony"
8
+
9
+ param "server", "The FTP server to connect to"
10
+ param "user", "The FTP user to use."
11
+ param "password", "Password for the given user."
12
+ param "directory", "The directory to watch."
13
+
14
+ option "--help", 0, "", "Show this help document."
15
+ option "--remote", 1, "path", "Remote path to use."
16
+ option "--timeout", 1, "seconds", "Length of time to allow files to transfer. (default 2)"
17
+ option "--coffee", 1, "target", "Automatically compile saved coffeescript files to the given directory."
18
+ option "--eco", 2, "target identifier", "Automatically compile saved eco files to the given directory."
19
+ option "--auto", 0, "", "Start up auto mode automatically."
20
+
21
+ help ""
22
+
23
+ def self.run
24
+ if @@options.include? "--help"
25
+ show_usage
26
+ exit
27
+ end
28
+
29
+ @modified = []
30
+ @active = false
31
+
32
+ @server = @@params["server"]
33
+ @user = @@params["user"]
34
+ @password = @@params["password"]
35
+ @directory = Dir.new(@@params["directory"])
36
+ @remote_path = @@options.include?("--remote") ? @@options["--remote"][0] : ""
37
+ @timeout = @@options.include?("--timeout") ? @@options["--timeout"][0].to_i : 2
38
+ @compile_coffeescript = @@options.include?("--coffee") ? @@options["--coffee"][0] : nil
39
+ @ignored = []
40
+
41
+ @compile_eco = nil
42
+ if @@options.include?("--eco")
43
+ @compile_eco = @@options["--eco"][0]
44
+ @eco_ident = @@options["--eco"][1]
45
+ end
46
+
47
+ @watcher = Dir::DirectoryWatcher.new(@directory)
48
+
49
+ @modified_proc = Proc.new do |file, info|
50
+ unless @ignored.include?(file.path)
51
+ @modified << file.path
52
+ if @active
53
+ if file.path.end_with?(".coffee") && @compile_coffeescript
54
+ `coffee -o #{@compile_coffeescript} -c #{file.path}`
55
+ end
56
+ if file.path.end_with?(".eco") && @compile_eco
57
+ `eco -i #{@eco_ident} -o #{@compile_eco} #{file.path}`
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ @watcher.on_add = @modified_proc
64
+ @watcher.on_modify = @modified_proc
65
+ #@watcher.on_remove = @modified_proc
66
+
67
+ @watcher.scan_now
68
+ self.clear
69
+ @active = true
70
+
71
+ puts " ## Harmony is now running".red
72
+
73
+ if File.exist?(".harmonyignore")
74
+ puts " ## Loading .harmonyignore".pink
75
+ File.readlines('.harmonyignore').each do |line|
76
+ @ignored << line[0..-2]
77
+ puts "X Ignoring #{line}".pink
78
+ end
79
+ end
80
+
81
+ self.start_auto if @@options.include?("--auto")
82
+
83
+ begin
84
+ while true
85
+ break if self.get_command
86
+ end
87
+ rescue => e
88
+ puts e.to_s.red
89
+ puts " ## FATAL ERROR OCCURRED. SHUTTING DOWN.".red
90
+ end
91
+ @thread.kill if @thread
92
+ self.close_connection
93
+ end
94
+
95
+ def self.get_command
96
+ print "Harmony:: ".yellow
97
+ command, arg = gets.chomp.split(' ')
98
+ return true if command == "exit" || command == "quit"
99
+ self.show_help if command == "help"
100
+ self.send_to_remote if command == "send" || command == "s"
101
+ self.clear if command == "clear"
102
+ self.show_status if command == "status" || command == "st"
103
+ self.deploy if command == "deploy"
104
+ self.start_auto if command == "auto"
105
+ self.stop_auto if command == "stop"
106
+ @modified_proc.call(File.new(arg), nil) if command == "mark"
107
+ self.ftp if command == "ftp"
108
+ false
109
+ end
110
+
111
+ def self.ftp
112
+ `ftp ftp://#{@user}:#{@password}@#{@server}`
113
+ end
114
+
115
+ def self.start_auto
116
+ puts " ## Auto upload has been started. Type 'stop' to kill the thread.".red
117
+ @thread = Thread.new do
118
+ begin
119
+ while true
120
+ self.send_to_remote
121
+ sleep 2
122
+ end
123
+ rescue => e
124
+ puts e.to_s.red
125
+ puts " ## FATAL ERROR OCCURRED IN AUTO THREAD. SHUTTING DOWN.".red
126
+ self.stop_auto
127
+ end
128
+ end
129
+ end
130
+
131
+ def self.stop_auto
132
+ return unless @thread
133
+ @thread.kill
134
+ puts " ## Auto upload thread has been killed.".red
135
+ end
136
+
137
+ def self.deploy
138
+ @watcher.add_all
139
+ self.send_to_remote
140
+ end
141
+
142
+ def self.send_to_remote
143
+ @watcher.scan_now
144
+ return if @modified.empty?
145
+ failed = !self.open_connection
146
+ unless failed
147
+ @modified.each do |file|
148
+ next if file.end_with? "~"
149
+ begin
150
+ Timeout::timeout(@timeout) do
151
+ rpath = self.remote_path_for(file)
152
+
153
+ if @ftp.mkdir_p rpath
154
+ puts " ## Created new directory #{rpath}".pink
155
+ end
156
+
157
+ @ftp.chdir rpath
158
+
159
+ if file.end_with? ".png", ".gif", ".jpg", ".bmp", ".svg", ".tiff", ".raw"
160
+ @ftp.putbinaryfile(file)
161
+ else
162
+ @ftp.puttextfile(file)
163
+ end
164
+ puts " ## [SUCCESS] #{file} => #{rpath}".green
165
+ end
166
+ rescue Timeout::Error
167
+ failed = true
168
+ puts " ## [ FAIL ] #{file} timed out while syncing".red
169
+ rescue Net::FTPError => e
170
+ failed = true
171
+ puts " ## [ FAIL ] #{file} failed to sync".red
172
+ puts e.message
173
+ end
174
+ end
175
+ end
176
+
177
+ if failed
178
+ puts " ## Some files failed to transfer. The dirty list has not been cleared.".pink
179
+ else
180
+ self.clear
181
+ end
182
+ end
183
+
184
+ def self.remote_path_for(file)
185
+ extra_path = file.sub(@directory.path, "")
186
+ @remote_path + extra_path[0, extra_path.rindex("/")]
187
+ end
188
+
189
+ def self.clear
190
+ @modified = []
191
+ end
192
+
193
+ def self.show_status
194
+ @watcher.scan_now
195
+
196
+ return puts " ## Directory is in sync".green if @modified.count == 0
197
+
198
+ puts " ## Files to be uploaded".red
199
+ @modified.each do |file|
200
+ puts "+ #{file}".pink
201
+ end
202
+ end
203
+
204
+ def self.show_help
205
+ puts " ## Harmony Help".red
206
+ puts "help - Show this help file"
207
+ puts "exit (quit) - Quit Harmony"
208
+ puts "status (st) - Show a list of files that will be transfered"
209
+ puts "clear - Mark all files as synced"
210
+ puts "send (s) - Send all new and modified files to the remote server"
211
+ puts "deploy - Send all files, regardless of their state"
212
+ puts "mark [file] - Mark the specified file as changed (use format ./directory/file)"
213
+ puts "auto - Automatically run 'send' every 2 seconds"
214
+ puts "stop - reverses the 'auto' command"
215
+ end
216
+
217
+ def self.open_connection
218
+ Timeout.timeout(@timeout) do
219
+ if @ftp
220
+ begin
221
+ @ftp.list
222
+ rescue Net::FTPError
223
+ puts " ## Connection was closed by server".pink
224
+ @ftp.close
225
+ end
226
+ end
227
+
228
+ if @ftp.nil? || @ftp.closed?
229
+ puts " ## Connection opening".red
230
+ @ftp = BetterFTP.new(@server, @user, @password)
231
+ end
232
+ end
233
+ true
234
+ rescue SocketError
235
+ puts " ## [FAIL] Unable to open connection to server.".red
236
+ false
237
+ rescue Timeout::Error
238
+ puts " ## [TIMEOUT] Failed to connect to server.".red
239
+ @ftp.close
240
+ @ftp = nil
241
+ false
242
+ end
243
+
244
+ def self.close_connection
245
+ if @ftp
246
+ puts " ## Connection closing".red
247
+ @ftp.close
248
+ end
249
+ end
250
+
251
+ end
252
+
253
+
254
+ # Monkey patching the string class for easy colors (you mad?)
255
+ class String
256
+ def colorize(color_code)
257
+ "\e[#{color_code}m#{self}\e[0m"
258
+ end
259
+
260
+ def red
261
+ colorize(31)
262
+ end
263
+
264
+ def green
265
+ colorize(32)
266
+ end
267
+
268
+ def yellow
269
+ colorize(33)
270
+ end
271
+
272
+ def pink
273
+ colorize(35)
274
+ end
275
+ end
276
+
277
+ require 'net/ftp'
278
+
279
+ class BetterFTP < Net::FTP
280
+
281
+ attr_accessor :port
282
+ attr_accessor :public_ip
283
+ alias_method :cd, :chdir
284
+ attr_reader :home
285
+
286
+ def initialize(host = nil, user = nil, passwd = nil, acct = nil)
287
+ super
288
+ @host = host
289
+ @user = user
290
+ @passwd = passwd
291
+ @acct = acct
292
+ @home = self.pwd
293
+ initialize_caches
294
+ end
295
+
296
+ def initialize_caches
297
+ @created_paths_cache = []
298
+ @deleted_paths_cache = []
299
+ end
300
+
301
+ def connect(host, port = nil)
302
+ port ||= @port || FTP_PORT
303
+ if @debug_mode
304
+ print "connect: ", host, ", ", port, "\n"
305
+ end
306
+ synchronize do
307
+ initialize_caches
308
+ @sock = open_socket(host, port)
309
+ voidresp
310
+ end
311
+ end
312
+
313
+ def reconnect!
314
+ if @host
315
+ connect(@host)
316
+ if @user
317
+ login(@user, @passwd, @acct)
318
+ end
319
+ end
320
+ end
321
+
322
+ def directory?(path)
323
+ chdir(path)
324
+
325
+ return true
326
+ rescue Net::FTPPermError
327
+ return false
328
+ end
329
+
330
+ def file?(path)
331
+ chdir(File.dirname(path))
332
+
333
+ begin
334
+ size(path)
335
+ return true
336
+ rescue Net::FTPPermError
337
+ return false
338
+ end
339
+ end
340
+
341
+ def mkdir_p(dir)
342
+ made_path = false
343
+
344
+ parts = dir.split("/")
345
+ if parts.first == "~"
346
+ growing_path = ""
347
+ else
348
+ growing_path = "/"
349
+ end
350
+ for part in parts
351
+ next if part == ""
352
+ if growing_path == ""
353
+ growing_path = part
354
+ else
355
+ growing_path = File.join(growing_path, part)
356
+ end
357
+ unless @created_paths_cache.include?(growing_path)
358
+ begin
359
+ mkdir(growing_path)
360
+ chdir(growing_path)
361
+ made_path = true
362
+ rescue Net::FTPPermError, Net::FTPTempError
363
+ end
364
+ @created_paths_cache << growing_path
365
+ else
366
+ end
367
+ end
368
+
369
+ made_path
370
+ end
371
+
372
+ def rm_r(path)
373
+ return if @deleted_paths_cache.include?(path)
374
+ @deleted_paths_cache << path
375
+ if directory?(path)
376
+ chdir path
377
+
378
+ begin
379
+ files = nlst
380
+ files.each {|file| rm_r "#{path}/#{file}"}
381
+ rescue Net::FTPTempError
382
+ # maybe all files were deleted already
383
+ end
384
+
385
+ rmdir path
386
+ else
387
+ rm(path)
388
+ end
389
+ end
390
+
391
+ def rm(path)
392
+ chdir File.dirname(path)
393
+ delete File.basename(path)
394
+ end
395
+
396
+ private
397
+
398
+ def makeport
399
+ sock = TCPServer.open(@sock.addr[3], 0)
400
+ port = sock.addr[1]
401
+ host = @public_ip || sock.addr[3]
402
+ sendport(host, port)
403
+ return sock
404
+ end
405
+
406
+ end
407
+
408
+
409
+ # *** This code is copyright 2004 by Gavin Kistner
410
+ # *** It is covered under the license viewable at http://phrogz.net/JS/_ReuseLicense.txt
411
+ class Dir
412
+
413
+ # The DirectoryWatcher class keeps an eye on all files in a directory, and calls
414
+ # methods of your making when files are added to, modified in, and/or removed from
415
+ # that directory.
416
+ #
417
+ # The +on_add+, +on_modify+ and +on_remove+ callbacks should be Proc instances
418
+ # that should be called when a file is added to, modified in, or removed from
419
+ # the watched directory.
420
+ #
421
+ # The +on_add+ and +on_modify+ Procs will be passed a File instance pointing to
422
+ # the file added/changed, as well as a Hash instance. The hash contains some
423
+ # saved statistics about the file (modification date (<tt>:date</tt>),
424
+ # file size (<tt>:size</tt>), and original path (<tt>:path</tt>)) and may also
425
+ # be used to store additional information about the file.
426
+ #
427
+ # The +on_remove+ Proc will only be passed the hash that was passed to +on_add+
428
+ # and +on_modify+ (since the file no longer exists in a way that the watcher
429
+ # knows about it). By storing your own information in the hash, you may do
430
+ # something intelligent when the file disappears.
431
+ #
432
+ # The first time the directory is scanned, the +on_add+ callback will be invoked
433
+ # for each file in the directory (since it's the first time they've been seen).
434
+ #
435
+ # Use the +onmodify_checks+ and +onmodify_requiresall+ properties to control
436
+ # what is required for the +on_modify+ callback to occur.
437
+ #
438
+ # If you do not set a Proc for any one of these callbacks; they'll simply
439
+ # be ignored.
440
+ #
441
+ # Use the +autoscan_delay+ property to set how frequently the directory is
442
+ # automatically checked, or use the #scan_now method to force it to look
443
+ # for changes.
444
+ #
445
+ # <b>You must call the #start_watching method after you create a DirectoryWatcher
446
+ # instance</b> (and after you have set the necessary callbacks) <b>to start the
447
+ # automatic scanning.</b>
448
+ #
449
+ # The DirectoryWatcher does not process sub-directories of the watched
450
+ # directory. Any item in the directory which returns +false+ for <tt>File.file?</tt>
451
+ # is ignored.
452
+ #
453
+ # Example:
454
+ #
455
+ # device_manager = Dir::DirectoryWatcher.new( 'plugins/devices', 2 )
456
+ # device_manager.name_regexp = /^[^.].*\.rb$/
457
+ #
458
+ # device_manager.on_add = Proc.new{ |the_file, stats_hash|
459
+ # puts "Hey, just found #{the_file.inspect}!"
460
+ #
461
+ # # Store something custom
462
+ # stats_hash[:blorgle] = the_file.foo
463
+ # }
464
+ #
465
+ # device_manager.on_modify = Proc.new{ |the_file, stats_hash|
466
+ # puts "Hey, #{the_file.inspect} just changed."
467
+ # }
468
+ #
469
+ # device_manager.on_remove = Proc.new{ |stats_hash|
470
+ # puts "Whoa, the following file just disappeared:"
471
+ # stats_hash.each_pair{ |k,v|
472
+ # puts "#{k} : #{v}"
473
+ # }
474
+ # }
475
+ #
476
+ # device_manager.start_watching
477
+ class DirectoryWatcher
478
+ # How long (in seconds) to wait between checks of the directory for changes.
479
+ attr_accessor :autoscan_delay
480
+
481
+ # The Dir instance or path to the directory to watch.
482
+ attr_accessor :directory
483
+ def directory=( dir ) #:nodoc:
484
+ @directory = dir.is_a?(Dir) ? dir : Dir.new( dir )
485
+ end
486
+
487
+ # Proc to call when files are added to the watched directory.
488
+ attr_accessor :on_add
489
+
490
+ # Proc to call when files are modified in the watched directory
491
+ # (see +onmodify_checks+).
492
+ attr_accessor :on_modify
493
+
494
+ # Proc to call when files are removed from the watched directory.
495
+ attr_accessor :on_remove
496
+
497
+ # Array of symbols which specify which attribute(s) to check for changes.
498
+ # Valid symbols are <tt>:date</tt> and <tt>:size</tt>.
499
+ # Defaults to <tt>:date</tt> only.
500
+ attr_accessor :onmodify_checks
501
+
502
+ # If more than one symbol is specified for +onmodify_checks+, should
503
+ # +on_modify+ be called only when *all* specified values change
504
+ # (value of +true+), or when any *one* value changes (value of +false+)?
505
+ # Defaults to +false+.
506
+ attr_accessor :onmodify_requiresall
507
+
508
+ # Should files which exist in the directory fire the +on_add+ callback
509
+ # the first time the directory is scanned? Defaults to +true+.
510
+ attr_accessor :onadd_for_existing
511
+
512
+ # Regular expression to match against file names. If +nil+, all files
513
+ # will be included, otherwise only those whose name match the regexp
514
+ # will be passed to the +on_add+/+on_modify+/+on_remove+ callbacks.
515
+ # Defaults to <tt>/^[^.].*$/</tt> (files which do not begin with a period).
516
+ attr_accessor :name_regexp
517
+
518
+ # Creates a new directory watcher.
519
+ #
520
+ # _dir_:: The path (relative to the current working directory) of the
521
+ # directory to watch, or a Dir instance.
522
+ # _delay_:: The +autoscan_delay+ value to use; defaults to 10 seconds.
523
+ def initialize( dir, delay = 10 )
524
+ self.directory = dir
525
+ @autoscan_delay = delay
526
+ @known_file_stats = {}
527
+ @onmodify_checks = [ :date ]
528
+ @onmodify_requiresall = false
529
+ @onadd_for_existing = true
530
+ @scanned_once = false
531
+ @name_regexp = /^[^.].*$/
532
+ end
533
+
534
+ # Starts the automatic scanning of the directory for changes,
535
+ # repeatedly calling #scan_now and then waiting +autoscan_delay+
536
+ # seconds before calling it again.
537
+ #
538
+ # Automatic scanning is *not* turned on when you create a new
539
+ # DirectoryWatcher; you must invoke this method (after setting
540
+ # the +on_add+/+on_modify+/+on_remove+ callbacks).
541
+ def start_watching
542
+ @thread = Thread.new{
543
+ while true
544
+ self.scan_now
545
+ sleep @autoscan_delay
546
+ end
547
+ }
548
+ end
549
+
550
+ # Stops the automatic scanning of the directory for changes.
551
+ def stop_watching
552
+ @thread.kill
553
+ end
554
+
555
+ # Scans the directory for additions/modifications/removals,
556
+ # calling the +on_add+/+on_modify+/+on_remove+ callbacks as
557
+ # appropriate.
558
+ def scan_now
559
+ #Check for add/modify
560
+ scan_dir(@directory)
561
+
562
+ # Check for removed files
563
+ if @on_remove.respond_to?( :call )
564
+ @known_file_stats.each_pair{ |path,stats|
565
+ next if File.file?( path )
566
+ stats[:path] = path
567
+ @on_remove.call( stats )
568
+ @known_file_stats.delete(path)
569
+ }
570
+ end
571
+
572
+ @scanned_once = true
573
+ end
574
+
575
+ def add_all
576
+ scan_dir(@directory, true)
577
+ end
578
+
579
+ def scan_dir(directory, override = false)
580
+ # Setup the checks
581
+ # ToDo: CRC
582
+ checks = {
583
+ :date => {
584
+ :use=>false,
585
+ :proc=>Proc.new{ |file,stats| stats.mtime }
586
+ },
587
+ :size => {
588
+ :use=>false,
589
+ :proc=>Proc.new{ |file,stats| stats.size }
590
+ },
591
+ :crc => {
592
+ :use=>false,
593
+ :proc=>Proc.new{ |file,stats| 1 }
594
+ }
595
+ }
596
+ checks.each_pair{ |check_name,check|
597
+ check[:use] = (@onmodify_checks == check_name) || ( @onmodify_checks.respond_to?( :include? ) && @onmodify_checks.include?( check_name ) )
598
+ }
599
+
600
+ directory.rewind
601
+ directory.each{ |fname|
602
+ next if fname.start_with? '.'
603
+ file_path = "#{directory.path}/#{fname}"
604
+ scan_dir(Dir.new(file_path), override) unless fname == "." || fname == ".." || File.file?(file_path)
605
+ next if (@name_regexp.respond_to?( :match ) && !@name_regexp.match( fname )) || !File.file?( file_path )
606
+ the_file = File.new( file_path )
607
+ file_stats = File.stat( file_path )
608
+
609
+ saved_stats = @known_file_stats[file_path]
610
+ new_stats = {}
611
+ checks.each_pair{ |check_name,check|
612
+ new_stats[check_name] = check[:proc].call( the_file, file_stats )
613
+ }
614
+
615
+ @on_add.call(the_file, new_stats) if override
616
+ if saved_stats
617
+ if @on_modify.respond_to?( :call )
618
+ sufficiently_modified = @onmodify_requiresall
619
+ saved_stats = @known_file_stats[file_path]
620
+ checks.each_pair{ |check_name,check|
621
+ stat_changed = check[:use] && ( saved_stats[check_name] != new_stats[check_name] )
622
+ if @onmodify_requiresall
623
+ sufficiently_modified &&= stat_changed
624
+ else
625
+ sufficiently_modified ||= stat_changed
626
+ end
627
+ saved_stats[check_name] = new_stats[check_name]
628
+ }
629
+ @on_modify.call( the_file, saved_stats ) if sufficiently_modified
630
+ end
631
+ elsif @on_add.respond_to?( :call ) && (@scanned_once || @onadd_for_existing)
632
+ @known_file_stats[file_path] = new_stats
633
+ @on_add.call( the_file, new_stats )
634
+ end
635
+
636
+ the_file.close
637
+ }
638
+ end
639
+
640
+ end
641
+
642
+ end
643
+
644
+ Harmony.start(ARGV)
metadata ADDED
@@ -0,0 +1,44 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: loft-harmony
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Caleb Simpson
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-07-10 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Watches a directory for changes to upload to a server via FTP
14
+ email: caleb@simpson.center
15
+ executables:
16
+ - harmony
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - bin/harmony
21
+ homepage:
22
+ licenses: []
23
+ metadata: {}
24
+ post_install_message:
25
+ rdoc_options: []
26
+ require_paths:
27
+ - lib
28
+ required_ruby_version: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ required_rubygems_version: !ruby/object:Gem::Requirement
34
+ requirements:
35
+ - - ">="
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ requirements: []
39
+ rubyforge_project:
40
+ rubygems_version: 2.2.3
41
+ signing_key:
42
+ specification_version: 4
43
+ summary: Watches a directory for changes to upload to a server via FTP
44
+ test_files: []