--- /dev/null
+# Copyright 2012 Jason Woofenden -- GPLv3
+
+fs = require 'fs'
+https = require 'https'
+async = require 'async'
+
+# SETTINGS
+HOST = 'api.appfog.com'
+PORT = 443
+
+# multipart/form-data isn't supposed to be chunk encoded, so we calculate the
+# length, and generate the textual parts as we go
+multipart_prepare = (boundary, data, callback) ->
+ arr = []
+ # async doesn't seem to work on dictionaries so convert to an array
+ for k, v of data
+ arr.push [k, v]
+ async.map arr,
+ ([k,v], callback) ->
+ out = "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{k}\""
+ if v.file
+ out += "; filename=\"#{v.file}\""
+ out += "\r\n"
+ if v instanceof Object
+ if v['Content-Type']?
+ ct = v['Content-Type']
+ else
+ if v.file?
+ ct = 'application/octet-stream'
+ else
+ ct = 'text/plain;charset=UTF-8'
+ out += "Content-Type: #{ct}\r\n\r\n"
+ if v.file?
+ fs.stat v.file, (err, stat) ->
+ return callback err if err?
+ callback null, text: out, file: v.file, file_length: stat.size
+ else
+ callback null, text: out
+ else
+ #out += "Content-Type: text/plain;charset=UTF-8\r\n\r\n"
+ #out += "Content-Type: text/plain\r\n\r\n"
+ out += "\r\n"
+ out += v.toString()
+ callback null, text: out
+ (err, parts) ->
+ return callback err if err
+ parts.push text: "--#{boundary}--"
+ size = 0
+ for part in parts
+ size += part.text.length
+ if part.file_length?
+ size += part.file_length
+ size += 2
+ callback null, size, parts
+
+# encode and print data (as created by multipart_prepare()) to writable stream
+# req (and end() it)
+multipart_write = (req, parts, callback) ->
+ async.forEachSeries parts,
+ (part, callback) ->
+ req.write part.text
+ if part.file?
+ done = false
+ file = fs.createReadStream part.file
+ file.on 'error', (err) ->
+ unless done
+ done = true
+ callback err
+ file.on 'end', ->
+ unless done
+ done = true
+ req.write '\r\n'
+ callback()
+ file.pipe req, end: false
+ return
+ else
+ req.write '\r\n'
+ callback()
+ (err) ->
+ req.end()
+ return callback err if err
+ callback()
+
+new_multipart_boundary = ->
+ return "----bmawvch#{Math.floor(Math.random() * 1000000000)}m#{Math.floor(Math.random() * 1000000000)}9rch48dh"
+
+request = (method, path, content_type, data, token, callback) ->
+ opts =
+ host: HOST
+ port: PORT
+ path: path
+ method: method
+ headers: 'Content-Type': content_type
+ opts.headers['Authorization'] = token if token?
+ # no Accept header: -> unsupported media type
+ opts.headers['Accept'] = '*/*;q=0.8' # -> bad request (in json)
+ #opts.headers['Accept'] = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' # -> bad request (in json) or blank 404
+ #opts.headers['Accept'] = 'application/json' -> unsupported media type
+ async.waterfall [
+ (callback) ->
+ if content_type is 'multipart/form-data'
+ boundary = new_multipart_boundary()
+ opts.headers['Content-Type'] = "#{content_type}; boundary=#{boundary}"
+ multipart_prepare boundary, data, (err, size, parts) ->
+ return callback err if err
+ opts.headers['Content-Length'] = size
+ callback null, parts
+ else
+ opts.headers['Content-Length'] = data?.length ? 0
+ callback null, data
+ (data, callback) ->
+ called = false
+ req = https.request opts, (res) ->
+ res.setEncoding 'utf8'
+ response_text = ''
+ res.on 'data', (data) ->
+ response_text += data
+ res.on 'end', ->
+ unless called
+ called = true
+ if res.statusCode isnt 200
+ callback code: res.statusCode, path: path, response: response_text
+ else
+ callback null, response_text
+ req.on 'error', (err) ->
+ unless called
+ called = true
+ callback err
+ if content_type is 'multipart/form-data'
+ multipart_write req, data, (err) ->
+ if err?
+ unless called
+ called = true
+ callback err
+ req.end()
+ else
+ req.write data if data?
+ req.end()
+ ], callback
+
+json_request = (method, path, data, token, callback) ->
+ if data?
+ data = JSON.stringify data
+ request method, path, 'application/json', data, token, (err, response) ->
+ return callback err if err
+ return callback() if response is ' '
+ return callback() if response is ''
+ try
+ callback null, JSON.parse(response)
+ catch error
+ callback "Error: AF server returned invalid JSON for #{method} to #{path}: \"#{response}\""
+
+json_get = (path, token, callback) ->
+ json_request 'GET', path, null, token, callback
+
+json_post = (path, data, token, callback) ->
+ json_request 'POST', path, data, token, callback
+
+json_put = (path, data, token, callback) ->
+ json_request 'PUT', path, data, token, callback
+
+exports.login = login = (username, password, callback) ->
+ json_post "/users/#{username}/tokens", {password: password}, null, (err, response) ->
+ return callback err if err
+ if response.token?.length
+ callback null, response.token
+ else
+ callback "login: couldn't find the token in the server response: #{JSON.stringify response}"
+
+# this is the friendly one that updates the files then restarts the app
+exports.app_publish = (token, app_name, zip_file, callback) ->
+ exports.app_update_files token, app_name, zip_file, (err) ->
+ return callback err if err?
+ # FIXME add "restart" call that only calls app_info once
+ exports.app_stop token, app_name, (err) ->
+ if err?
+ return callback "Couldn't stop the app (to restart it). App is probably still running old version (though perhaps accessing new files.). Server responded: #{err}"
+ exports.app_start token, app_name, (err) ->
+ if err?
+ return callback "CRITICAL ERROR: Couldn't start the app back up! Server responded: #{err}"
+ callback()
+
+exports.app_update_files = (token, app_name, zip_file, callback) ->
+ request(
+ 'POST',
+ "/apps/#{app_name}/application",
+ 'multipart/form-data',
+ {
+ _method: 'put',
+ resources: '[]',
+ application:
+ file: zip_file,
+ "Content-Type": 'application/octet-stream'
+ },
+ token,
+ callback
+ )
+
+exports.app_set_info = (token, app_name, info, callback) ->
+ json_put "/apps/#{app_name}", info, token, callback
+
+app_set_state = (token, app_name, state, callback) ->
+ exports.app_info token, app_name, (err, info) ->
+ return callback err if err?
+ info.state = state
+ exports.app_set_info token, app_name, info, callback
+
+exports.app_start = (token, app_name, callback) ->
+ app_set_state token, app_name, 'STARTED', callback
+
+exports.app_stop = (token, app_name, callback) ->
+ app_set_state token, app_name, 'STOPPED', callback
+
+
+app_get = (path) ->
+ return (token, app_name, callback) ->
+ json_get "/apps/#{app_name}#{path}", token, callback
+
+exports.app_instances = app_get '/instances'
+exports.app_crashes = app_get '/crashes'
+exports.app_info = app_get ''
+exports.app_logs = app_get '/instances/0/files/logs'
+exports.app_stdout = app_get '/instances/0/files/logs/stdout.log'
+exports.app_stderr = app_get '/instances/0/files/logs/stderr.log'
+exports.app_files = app_get '/instances/0/files/app/app.js'
+exports.app_stats = app_get '/stats'
--- /dev/null
+fs = require 'fs'
+async = require 'async'
+af = require './api.coffee'
+
+token_file = "#{process.env.HOME}/.af-coffee-token"
+
+timeout = (ms, f) -> setTimeout f, ms
+
+# a session caches the api access token and prompts for the username and
+# password if it goes stale. It re-tries API calls that fail due to
+# invalid/expired token
+
+class Session
+ constructor: ->
+ @token = 0
+
+ api: (call, args..., callback) ->
+ async.waterfall [
+ (callback) =>
+ switch @token
+ when 0
+ get_token callback
+ when -1
+ login callback
+ else
+ callback null, @token
+ (token, callback) =>
+ @token = token
+ af[call] @token, args..., callback
+ ], (err, result) =>
+ # eg /app/xxx/stats sometimes returns 404 with wrong auth token
+ if err?.code is 403 or err?.code is 404
+ @token = -1
+ @api(call, args..., callback)
+ else
+ callback err, result
+
+ask = (opts, callback) ->
+ process.stdout.write opts.prompt
+ process.stdin.setEncoding 'utf8'
+ process.stdin.resume()
+ process.stdin.once 'data', (line) ->
+ if opts.silent
+ # send ^[[A^[[2K to move the cursor up one line, then clear that line
+ process.stdout.write new Buffer [27, 91, 65, 27, 91, 50, 75]
+ process.stdout.write opts.prompt + "***\n"
+ process.stdin.pause()
+ callback null, (line.substr 0, line.length - 1)
+
+get_token = (callback) ->
+ fs.readFile token_file, 'utf8', (err, token) ->
+ if err?
+ login callback
+ else
+ callback null, token
+
+login = (callback) ->
+ async.waterfall [
+ (callback) -> async.series [
+ (callback) -> ask prompt: 'username: ', callback
+ (callback) -> ask prompt: 'password: ', silent: true, callback
+ ], callback
+ ([username, password], callback) ->
+ af.login username, password, callback
+ (token, callback) ->
+ # wait for file write so there's no race condition if get_token gets called soon
+ fs.writeFile token_file, token, (err) ->
+ if err
+ console.log "Warning: couldn't cache auth token in #{token_file}: ", err
+ # don't pass on error, it's ok if we can't cache it
+ callback null, token
+ ], callback
+
+
+exports.new_session = ->
+ return new Session()