Compare commits

...
Author SHA1 Message Date
Zaid Abdulla b91bcb7442 1.0.4 2015-05-31 19:40:33 +03:00
Zaid Abdulla a0c0a7938b Updated dependency version ranges. 2015-05-31 19:39:12 +03:00
Dan Morphis 82f354bf2b Set the status code to 400 when invalid query params are sent 2015-05-31 13:38:47 +03:00
Dan Morphis 7f64b567db Don't crash if the query params are invalid json 2015-05-31 13:38:47 +03:00
Dan Morphis b17f842de7 Return the task object when adding a task 2015-05-31 13:38:15 +03:00
Zaid Abdulla ff68553bd7 No longer storing or relying on object instance names as that proved to be unnecessary and can potentially cause unpredictable behavior in certain edge cases. 2015-05-31 03:04:26 +03:00
Zaid Abdulla 22b11d6103 Updated README file. 2015-05-31 00:24:05 +03:00
Zaid Abdulla 00987ea8e2 Searching by MAC now works regardless of whether the device's MAC is lower or upper case. 2015-05-30 01:49:52 +03:00
Zaid Abdulla f0a1289818 Modified sample configuration for wider device compatibility. 2015-05-29 14:21:16 +03:00
Zaid Abdulla 38840d5b8d Added debug dump files to .gitignore. 2015-05-29 14:12:19 +03:00
Zaid Abdulla 74008bc7de Implemented a workaround for certain devices dropping connection after digest authentication challege in connection requests. 2015-05-29 14:11:05 +03:00
Zaid Abdulla 305558f186 Fixed ability to query tasks collection using task ID. Fixes #68. 2015-05-26 12:54:04 +03:00
Zaid Abdulla 4ad8aa5a1b Fixed bug in addObject task where a given parameter value may be set to all descendant parameters wth the same name. 2015-05-26 00:44:50 +03:00
Zaid Abdulla c70354997a Support nested parameters in object configurations in presets. 2015-05-25 11:22:53 +03:00
Zaid Abdulla c88e5d6fe8 Fixed bug causing only a single preset configuration of custom_command_age and custom_comand_value to take affect. 2015-02-01 19:59:53 +03:00
Zaid Abdulla 4e9186812c 1.0.3 2015-01-13 23:01:21 +03:00
Zaid Abdulla 41740cbf0b Fixed 404 error when deleting/retrying a task caused by recent change in node's implementation of querystring.unescape. 2014-11-22 19:11:41 +03:00
Zaid Abdulla 2932b9ab6d Correctly use https scheme for download RPC when FS_SSL config is set. Fixes #43. 2014-11-14 17:23:08 +03:00
Zaid Abdulla 3625f26134 1.0.2 2014-09-07 20:46:13 +03:00
Zaid Abdulla c1d486b776 Updated readme file. 2014-09-07 20:45:17 +03:00
Zaid Abdulla ae441beda0 1.0.1 2014-09-05 16:06:45 +03:00
12 changed files with 121 additions and 66 deletions

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
*~ *~
*.js *.js
node_modules node_modules
debug/*
+5 -6
View File
@@ -19,10 +19,10 @@ Install [Node.js](http://nodejs.org/), [Redis](http://redis.io/), and [MongoDB](
You may need to modify the configuration files under "config" directory (in /lib/node_modules/genieacs/config) depending on your setup. You may need to modify the configuration files under "config" directory (in /lib/node_modules/genieacs/config) depending on your setup.
Alternatively, you can use git to get the latest development version (not recommended for production use): Alternatively, you can install from source by cloning the git repository:
cd /opt cd /opt
git clone https://github.com/zaidka/genieacs.git git clone https://github.com/zaidka/genieacs.git --branch v1.0
cd genieacs cd genieacs
npm install npm install
npm run configure npm run configure
@@ -54,13 +54,12 @@ You may now proceed with installing [GenieACS GUI front end](https://github.com/
## Support ## Support
Documentation is still work in progress. Feel free to contact me if you require any assistance in installation, configuration, or using the APIs. The [Users mailing list](http://lists.genieacs.com) is a good place to get guidance and help from the community. Head on over and join the conversation! In addition, the [wiki](https://github.com/zaidka/genieacs/wiki) provides useful documentation and tips from GenieACS users.
You may submit bug reports or feature requests [here](https://github.com/zaidka/genieacs/issues). You may submit bug reports or feature requests [here](https://github.com/zaidka/genieacs/issues). For device interoperability issues, please consult the mailing list first — it's likely that a workaround already exists.
For commercial support options, please contact me. For commercial support options and professional services, please visit [genieacs.com](https://genieacs.com).
## Contributing ## Contributing
Contributions are welcome. Fork this repo and open a pull request and wait for feedback. You can also contribute by enhancing the documentation in the [wiki section](https://github.com/zaidka/genieacs/wiki). Contributions are welcome. Fork this repo and open a pull request and wait for feedback. You can also contribute by enhancing the documentation in the [wiki section](https://github.com/zaidka/genieacs/wiki).
+9 -1
View File
@@ -8,12 +8,20 @@
"_deviceId._Manufacturer" : {"alias" : "summary.manufacturer"}, "_deviceId._Manufacturer" : {"alias" : "summary.manufacturer"},
"InternetGatewayDevice.DeviceInfo.HardwareVersion" : {"alias" : "summary.hardwareVersion"}, "InternetGatewayDevice.DeviceInfo.HardwareVersion" : {"alias" : "summary.hardwareVersion"},
"InternetGatewayDevice.DeviceInfo.SoftwareVersion" : {"alias" : "summary.softwareVersion"}, "InternetGatewayDevice.DeviceInfo.SoftwareVersion" : {"alias" : "summary.softwareVersion"},
"Device.DeviceInfo.HardwareVersion" : {"alias" : "summary.hardwareVersion"},
"Device.DeviceInfo.SoftwareVersion" : {"alias" : "summary.softwareVersion"},
"InternetGatewayDevice.WANDevice./^\\d+$/.WANConnectionDevice./^\\d+$/.WANIPConnection./^\\d+$/.MACAddress" : {"alias" : "summary.mac", "type" : "mac"}, "InternetGatewayDevice.WANDevice./^\\d+$/.WANConnectionDevice./^\\d+$/.WANIPConnection./^\\d+$/.MACAddress" : {"alias" : "summary.mac", "type" : "mac"},
"InternetGatewayDevice.WANDevice./^\\d+$/.WANConnectionDevice./^\\d+$/.WANIPConnection./^\\d+$/.ExternalIPAddress" : {"alias" : "summary.ip"}, "InternetGatewayDevice.WANDevice./^\\d+$/.WANConnectionDevice./^\\d+$/.WANIPConnection./^\\d+$/.ExternalIPAddress" : {"alias" : "summary.ip"},
"InternetGatewayDevice.WANDevice./^\\d+$/.WANConnectionDevice./^\\d+$/.WANPPPConnection./^\\d+$/.MACAddress" : {"alias" : "summary.mac", "type" : "mac"}, "InternetGatewayDevice.WANDevice./^\\d+$/.WANConnectionDevice./^\\d+$/.WANPPPConnection./^\\d+$/.MACAddress" : {"alias" : "summary.mac", "type" : "mac"},
"InternetGatewayDevice.WANDevice./^\\d+$/.WANConnectionDevice./^\\d+$/.WANPPPConnection./^\\d+$/.ExternalIPAddress" : {"alias" : "summary.ip"}, "InternetGatewayDevice.WANDevice./^\\d+$/.WANConnectionDevice./^\\d+$/.WANPPPConnection./^\\d+$/.ExternalIPAddress" : {"alias" : "summary.ip"},
"Device.WANDevice./^\\d+$/.WANConnectionDevice./^\\d+$/.WANIPConnection./^\\d+$/.MACAddress" : {"alias" : "summary.mac", "type" : "mac"},
"Device.WANDevice./^\\d+$/.WANConnectionDevice./^\\d+$/.WANIPConnection./^\\d+$/.ExternalIPAddress" : {"alias" : "summary.ip"},
"Device.WANDevice./^\\d+$/.WANConnectionDevice./^\\d+$/.WANPPPConnection./^\\d+$/.MACAddress" : {"alias" : "summary.mac", "type" : "mac"},
"Device.WANDevice./^\\d+$/.WANConnectionDevice./^\\d+$/.WANPPPConnection./^\\d+$/.ExternalIPAddress" : {"alias" : "summary.ip"},
"InternetGatewayDevice.LANDevice./^\\d+$/.WLANConfiguration./^\\d+$/.SSID" : {"alias" : "summary.wlanSsid"}, "InternetGatewayDevice.LANDevice./^\\d+$/.WLANConfiguration./^\\d+$/.SSID" : {"alias" : "summary.wlanSsid"},
"InternetGatewayDevice.LANDevice./^\\d+$/.WLANConfiguration./^\\d+$/.PreSharedKey.1.KeyPassphrase" : {"alias" : "summary.wlanPassphrase"} "InternetGatewayDevice.LANDevice./^\\d+$/.WLANConfiguration./^\\d+$/.PreSharedKey.1.KeyPassphrase" : {"alias" : "summary.wlanPassphrase"},
"Device.LANDevice./^\\d+$/.WLANConfiguration./^\\d+$/.SSID" : {"alias" : "summary.wlanSsid"},
"Device.LANDevice./^\\d+$/.WLANConfiguration./^\\d+$/.PreSharedKey.1.KeyPassphrase" : {"alias" : "summary.wlanPassphrase"}
} }
+3
View File
@@ -117,6 +117,9 @@ connectionRequest = (deviceId, callback) ->
authString = auth.digest(username, password, uri.path, 'GET', null, authHeader) authString = auth.digest(username, password, uri.path, 'GET', null, authHeader)
conReq(connectionRequestUrl, authString, (statusCode, authHeader) -> conReq(connectionRequestUrl, authString, (statusCode, authHeader) ->
if statusCode == 0
# Workaround for some devices unexpectedly closing the connection
return conReq(connectionRequestUrl, authString, (statusCode) -> callback(statusToError(statusCode)))
callback(statusToError(statusCode)) callback(statusToError(statusCode))
) )
else else
+12
View File
@@ -86,6 +86,18 @@ exports.extend = (obj, mixin) ->
obj obj
exports.flattenObject = (object) ->
newObj = {}
f = (obj, prefix) ->
for k, v of obj
if typeof(v) is 'object'
f(v, "#{prefix}#{k}.")
else
newObj["#{prefix}#{k}"] = v
f(object, '')
return newObj
exports.getParamValueFromPath = (obj, path) -> exports.getParamValueFromPath = (obj, path) ->
pp = path.split('.') pp = path.split('.')
ref = obj ref = obj
+16 -10
View File
@@ -196,21 +196,21 @@ listener = (request, response) ->
if urlParts.query.connection_request? if urlParts.query.connection_request?
apiFunctions.connectionRequest(deviceId, (err) -> apiFunctions.connectionRequest(deviceId, (err) ->
if err if err
response.writeHead(202) response.writeHead(202, {'Content-Type' : 'application/json'})
response.end() response.end(JSON.stringify(task))
else else
apiFunctions.watchTask(task._id, config.get('DEVICE_ONLINE_THRESHOLD', deviceId), (err) -> apiFunctions.watchTask(task._id, config.get('DEVICE_ONLINE_THRESHOLD', deviceId), (err) ->
if err if err
response.writeHead(202) response.writeHead(202, {'Content-Type' : 'application/json'})
response.end() response.end(JSON.stringify(task))
return return
response.writeHead(200) response.writeHead(200, {'Content-Type' : 'application/json'})
response.end() response.end(JSON.stringify(task))
) )
) )
else else
response.writeHead(202) response.writeHead(202, {'Content-Type' : 'application/json'})
response.end() response.end(JSON.stringify(task))
) )
) )
else if urlParts.query.connection_request? else if urlParts.query.connection_request?
@@ -244,7 +244,7 @@ listener = (request, response) ->
else if TASKS_REGEX.test(urlParts.pathname) else if TASKS_REGEX.test(urlParts.pathname)
r = TASKS_REGEX.exec(urlParts.pathname) r = TASKS_REGEX.exec(urlParts.pathname)
taskId = mongodb.ObjectID(querystring.unescape(r[1])) taskId = mongodb.ObjectID(querystring.unescape(r[1]))
action = try querystring.unescape(r[2]) action = r[2]
if not action? or action is '/' if not action? or action is '/'
if request.method == 'DELETE' if request.method == 'DELETE'
db.tasksCollection.remove({'_id' : taskId}, (err, removedCount) -> db.tasksCollection.remove({'_id' : taskId}, (err, removedCount) ->
@@ -339,10 +339,16 @@ listener = (request, response) ->
func = (aliases) -> func = (aliases) ->
if urlParts.query.query? if urlParts.query.query?
q = JSON.parse(urlParts.query.query) try
q = JSON.parse(urlParts.query.query)
catch err
response.writeHead(400)
response.end(err.toString())
return
else else
q = {} q = {}
q = query.expand(q, aliases) if collectionName is 'devices' q = query.expand(q, aliases) if collectionName is 'devices'
q = query.substituteObjectId(q) if collectionName is 'tasks'
if urlParts.query.projection? if urlParts.query.projection?
projection = {} projection = {}
+5 -5
View File
@@ -27,7 +27,7 @@ common = require './common'
parameters = require './parameters' parameters = require './parameters'
stringToRegexp = (input) -> stringToRegexp = (input, flags) ->
if (input.indexOf('*') == -1) if (input.indexOf('*') == -1)
return false return false
@@ -44,7 +44,7 @@ stringToRegexp = (input) ->
output = output.replace(/[\*]/, '.*') output = output.replace(/[\*]/, '.*')
return new RegExp(output) return new RegExp(output, flags)
normalizers = {} normalizers = {}
@@ -117,14 +117,14 @@ normalizers.mac = (input, normType) ->
if input.indexOf('*') != -1 if input.indexOf('*') != -1
input = (colonizeMac(a) for a in input.split('*')).join('*') input = (colonizeMac(a) for a in input.split('*')).join('*')
return stringToRegexp(input, false) return stringToRegexp(input, 'i')
input = colonizeMac(input) input = colonizeMac(input)
if input.length == 17 if input.length == 17
return input return [input, input.toLowerCase()]
return {'$regex' : new RegExp(input)} return {'$regex' : new RegExp(input, 'i')}
exports.normalize = (path, value, normType) -> exports.normalize = (path, value, normType) ->
+28 -31
View File
@@ -54,12 +54,21 @@ exports.calculatePresetsHash = (presets, objects) ->
matchObject = (object, param) -> matchObject = (object, param) ->
return false if not object._keys? or object._keys.length == 0 flatObject = common.flattenObject(object)
for k in object._keys if object._keys?.length
return false if not param[k]? keys = object._keys
v = common.matchType(param[k]._value, object[k]) else
if param[k]._value != v # Consider all parameters as keys if none are defined
keys = (k for k in Object.keys(flatObject) when k[0] != '_')
for k in keys
p = common.getParamValueFromPath(param, k)
return false if not p?
v = common.matchType(p._value, flatObject[k])
if p._value != common.matchType(p._value, flatObject[k])
return false return false
return true return true
@@ -146,15 +155,7 @@ exports.getDevicePreset = (deviceId, presets, objects, aliases, callback) ->
devicePreset.objects[c.name][c.object].current ?= {} devicePreset.objects[c.name][c.object].current ?= {}
for k,p of param for k,p of param
continue if k[0] == '_' continue if k[0] == '_'
if p._name? if matchObject(objects[c.object], p)
if p._name == c.object
devicePreset.objects[c.name][c.object].current[k] = p
else if matchObject(objects[c.object], p)
u = {}
u["#{c.name}.#{k}._name"] = c.object
db.devicesCollection.update({'_id' : deviceId}, {'$set' : u}, {}, (err, count) ->
throw err if err
)
devicePreset.objects[c.name][c.object].current[k] = p devicePreset.objects[c.name][c.object].current[k] = p
when 'delete_object' when 'delete_object'
@@ -165,15 +166,7 @@ exports.getDevicePreset = (deviceId, presets, objects, aliases, callback) ->
devicePreset.objects[c.name][c.object].current ?= {} devicePreset.objects[c.name][c.object].current ?= {}
for k,p of param for k,p of param
continue if k[0] == '_' continue if k[0] == '_'
if p._name? if matchObject(objects[c.object], p)
if p._name == c.object
devicePreset.objects[c.name][c.object].current[k] = p
else if matchObject(objects[c.object], p)
u = {}
u["#{c.name}.#{k}._name"] = c.object
db.devicesCollection.update({'_id' : deviceId}, {'$set' : u}, {}, (err, count) ->
throw err if err
)
devicePreset.objects[c.name][c.object].current[k] = p devicePreset.objects[c.name][c.object].current[k] = p
when 'custom_command_value' when 'custom_command_value'
@@ -250,20 +243,21 @@ exports.processDevicePreset = (deviceId, devicePreset, callback) ->
for objectName, objectDetails of object for objectName, objectDetails of object
continue if not objectDetails.current? continue if not objectDetails.current?
if objectDetails.preset? if objectDetails.preset?
flatObject = common.flattenObject(objectDetails.preset)
if Object.keys(objectDetails.current).length > 0 if Object.keys(objectDetails.current).length > 0
for i, obj of objectDetails.current for i, obj of objectDetails.current
for paramName, paramValue of objectDetails.preset for paramName, paramValue of flatObject
continue if paramName[0] == '_' continue if paramName[0] == '_'
if obj[paramName] currentValue = common.getParamValueFromPath(obj, paramName)
currentValue = obj[paramName]._value if currentValue?
presetValue = common.matchType(currentValue, objectDetails.preset[paramName]) presetValue = common.matchType(currentValue._value, flatObject[paramName])
if currentValue != presetValue if currentValue._value != presetValue
setParameterValues.push(["#{parameterPath}.#{i}.#{paramName}", presetValue, obj[paramName]._type]) setParameterValues.push(["#{parameterPath}.#{i}.#{paramName}", presetValue, currentValue._type])
else else
vals = [] vals = []
for k,v of objectDetails.preset for k,v of flatObject
vals.push([k, v]) if k[0] != '_' vals.push([k, v]) if k[0] != '_'
taskList.push({device : deviceId, name : 'addObject', objectName : parameterPath, parameterValues : vals, instanceName : objectName}) taskList.push({device : deviceId, name : 'addObject', objectName : parameterPath, parameterValues : vals})
else if Object.keys(objectDetails.current).length > 0 else if Object.keys(objectDetails.current).length > 0
for i, obj of objectDetails.current for i, obj of objectDetails.current
taskList.push({device : deviceId, name : 'deleteObject', objectName : "#{parameterPath}.#{i}"}) taskList.push({device : deviceId, name : 'deleteObject', objectName : "#{parameterPath}.#{i}"})
@@ -320,6 +314,9 @@ accumulateConfigurations = (presets, objects) ->
"object_#{c.name}_#{objectHash}" "object_#{c.name}_#{objectHash}"
when 'software_version' when 'software_version'
'software_version' 'software_version'
when 'custom_command_age', 'custom_command_value'
[filename, commandName] = c.command.split(' ', 2)
"#{c.type}_#{filename}_#{commandName}"
else else
"#{c.type}_#{c.name}" "#{c.type}_#{c.name}"
+26
View File
@@ -22,6 +22,7 @@
common = require './common' common = require './common'
normalize = require './normalize' normalize = require './normalize'
mongodb = require 'mongodb'
expandValue = (param, value) -> expandValue = (param, value) ->
@@ -103,5 +104,30 @@ expand = (query, aliases) ->
return new_query return new_query
# Replace _id string values with ObjectID type
substituteObjectId = (query) ->
for k, v of query
if k[0] == '$' # logical operator
for i in [0...v.length]
substituteObjectId(v[i])
else if k == '_id'
if common.typeOf(v) is common.STRING_TYPE
query[k] = mongodb.ObjectID(v) if v.length == 24
else if common.typeOf(v) is common.OBJECT_TYPE
for kk, vv of v
switch kk # operator
when '$in', '$nin'
for i in [0...vv.length]
vv[i] = mongodb.ObjectID(vv[i]) if vv[i].length == 24
when '$eq', '$gt', '$gte', '$lt', '$lte', '$ne'
v[kk] = mongodb.ObjectID(vv) if vv.length == 24
when '$exists', '$type' then
# ignore
else
throw new Error('Operator not supported')
return query
exports.expand = expand exports.expand = expand
exports.substituteObjectId = substituteObjectId
+10 -3
View File
@@ -43,6 +43,7 @@ common = require './common'
db = require './db' db = require './db'
mongodb = require 'mongodb' mongodb = require 'mongodb'
customCommands = require './custom-commands' customCommands = require './custom-commands'
url = require 'url'
exports.STATUS_OK = STATUS_OK = 1 exports.STATUS_OK = STATUS_OK = 1
exports.STATUS_FAULT = STATUS_FAULT = 2 exports.STATUS_FAULT = STATUS_FAULT = 2
@@ -220,7 +221,6 @@ this.addObject = (task, methodResponse, callback) ->
task.session.subtask = {device : task.device, name : 'getParameterNames', parameterPath : "#{task.objectName}.#{task.session.instanceNumber}.", nextLevel : false} task.session.subtask = {device : task.device, name : 'getParameterNames', parameterPath : "#{task.objectName}.#{task.session.instanceNumber}.", nextLevel : false}
task.session.appliedParameterValues = [] task.session.appliedParameterValues = []
task.session.parameterNames = [] task.session.parameterNames = []
allDeviceUpdates.instanceName = [["#{task.objectName}.#{task.session.instanceNumber}", task.instanceName]] if task.instanceName?
else else
callback(null, STATUS_OK, {type : 'AddObject', objectName : "#{task.objectName}."}) callback(null, STATUS_OK, {type : 'AddObject', objectName : "#{task.objectName}."})
return return
@@ -251,7 +251,7 @@ this.addObject = (task, methodResponse, callback) ->
if deviceUpdates and deviceUpdates.parameterValues? if deviceUpdates and deviceUpdates.parameterValues?
for p1 in deviceUpdates.parameterValues for p1 in deviceUpdates.parameterValues
for p2 in task.parameterValues for p2 in task.parameterValues
if common.endsWith(p1[0], ".#{p2[0]}") if p1[0] == "#{task.objectName}.#{task.session.instanceNumber}.#{p2[0]}"
t = if p2[2] then p2[2] else p1[2] t = if p2[2] then p2[2] else p1[2]
v = common.matchType(p1[1], p2[1]) v = common.matchType(p1[1], p2[1])
# TODO only include if writable # TODO only include if writable
@@ -330,11 +330,18 @@ this.download = (task, methodResponse, callback) ->
callback(err) callback(err)
return return
l = {
protocol : if config.get('FS_SSL') then 'https' else 'http',
hostname : config.get('FS_IP'),
port : config.get('FS_PORT'),
pathname : encodeURIComponent(file.filename)
}
methodRequest = { methodRequest = {
type : 'Download', type : 'Download',
fileType : file.metadata.fileType, fileType : file.metadata.fileType,
fileSize : file.length, fileSize : file.length,
url : "http://#{config.get('FS_IP')}:#{config.get('FS_PORT')}/#{encodeURIComponent(file.filename)}", url : url.format(l),
successUrl : task.successUrl, successUrl : task.successUrl,
failureUrl : task.failureUrl failureUrl : task.failureUrl
} }
-4
View File
@@ -75,10 +75,6 @@ commitUpdates = (deviceId, deviceUpdates, commitQueue, callback) ->
for p in updatesBatch.deletedObjects for p in updatesBatch.deletedObjects
update['$unset'][p] = 1 update['$unset'][p] = 1
if updatesBatch.instanceName?
for i in updatesBatch.instanceName
update['$set']["#{i[0]}._name"] = i[1]
if updatesBatch.parameterNames? if updatesBatch.parameterNames?
presentPaths ?= {} presentPaths ?= {}
projection ?= {} projection ?= {}
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "genieacs", "name": "genieacs",
"version": "1.0.0", "version": "1.0.4",
"description": "A blazing fast TR-069 auto configuration server (ACS) built with Node.js, Redis, and MongoDB", "description": "A blazing fast TR-069 auto configuration server (ACS) built with Node.js, Redis, and MongoDB",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -20,13 +20,13 @@
"bin": "bin" "bin": "bin"
}, },
"dependencies": { "dependencies": {
"libxmljs": "~0.11.0", "libxmljs": "0.11.0 - 0.14.x",
"redis": "~0.12.1", "redis": "^0.12.1",
"hiredis": "~0.1.17", "hiredis": "0.1.17 - 0.4.x",
"mongodb": "~1.4.8" "mongodb": "^1.4.8"
}, },
"devDependencies": { "devDependencies": {
"coffee-script": "~1.7.1" "coffee-script": "^1.7.1"
}, },
"engines": { "engines": {
"node": ">=0.10.0 <0.12" "node": ">=0.10.0 <0.12"