loft-harmony 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []