pimatic
Version:
A home automation server and framework for the Raspberry PI running on node.js
637 lines (565 loc) • 21.3 kB
text/coffeescript
###
Plugin Manager
=======
###
Promise = require 'bluebird'
fs = require 'fs.extra'; Promise.promisifyAll(fs)
path = require 'path'
util = require 'util'
assert = require 'cassert'
byline = require 'byline'
_ = require 'lodash'
spawn = require("cross-spawn")
https = require "https"
semver = require "semver"
events = require 'events'
S = require 'string'
declapi = require 'decl-api'
rp = require 'request-promise'
download = require 'gethub'
blacklist = require '../blacklist.json'
module.exports = (env) ->
isCompatible = (refVersion, packageInfo) ->
try
peerVersion = packageInfo.peerDependencies?.pimatic
if peerVersion?
if semver.satisfies(refVersion, peerVersion)
return true
catch err
env.logger.error(err)
return false
satisfyingVersion = (p, refVersion) ->
versions = []
_.forEach(p.versions, (value, key) =>
if isCompatible(refVersion, value)
versions.push key
)
return versions
getLatestCompatible = (packageInfo, refVersion) ->
result = packageInfo.versions[packageInfo['dist-tags'].latest]
if isCompatible(refVersion, result)
return result
else
satisfyingV = satisfyingVersion(packageInfo, refVersion)
if satisfyingV.length > 0
latestSatisfying = satisfyingV[satisfyingV.length-1]
result = packageInfo.versions[latestSatisfying]
return result
else
# no compatible version found, return latest
return result
return result
class PluginManager extends events.EventEmitter
plugins: []
updateProcessStatus: 'idle'
updateProcessMessages: []
restartRequired: false
constructor: () ->
= path.resolve .maindir, '../../'
checkNpmVersion: () ->
.catch( (err) =>
env.logger.error("Could not run ppm, plugin and module installation will not work.")
)
# Loads the given plugin by name
loadPlugin: (name, config) ->
packageInfo =
packageInfoStr = (if packageInfo? then "(" + packageInfo.version + ")" else "")
env.logger.info("""Loading plugin: "#{name}" #{packageInfoStr}""")
# require the plugin and return it
# create a sublogger:
pluginEnv = Object.create(env)
pluginEnv.logger = env.logger.base.createSublogger(name, config.debug)
if config.debug
env.logger.debug("debug is true in plugin config, showing debug output for #{name}.")
plugin = (require name) pluginEnv, module
return Promise.resolve([plugin, packageInfo])
# Checks if the plugin folder exists under node_modules
isInstalled: (name) ->
assert name?
assert name.match(/^pimatic.*$/)?
return fs.existsSync( name)
isGitRepo: (name) ->
assert name?
assert name.match(/^pimatic.*$/)?
return fs.existsSync("#{@pathToPlugin name}/.git")
_getFullPlatfrom: ->
abiVersion = process.versions.modules
platform = process.platform
arch = if process.arch is "arm" then "armhf" else process.arch
return "node-#{abiVersion}-#{arch}-#{platform}"
_findDist: (plugin) ->
if (not plugin.dists?) or plugin.dists.length is 0 then return null
fullPlatform =
for dist in plugin.dists
if dist.name.indexOf(fullPlatform) is 0
return dist
return null
# Install a plugin from the npm repository
installPlugin: (name, update = false) ->
assert name?
assert name.match(/^pimatic.*$/)?
if update
if then throw new Error("Can't update a git repository!")
return .then( (packageInfo) =>
unless packageInfo?
env.logger.warn(
"Could not determine compatible version for \"#{name}\"" +
", trying to installing latest version"
)
env.logger.info("Installing: \"#{name}\" from npm-registry.")
if update
return
else
return
dist =
if dist
return if update then else
env.logger.info("Installing: \"#{name}@#{packageInfo.version}\" from npm-registry.")
return
)
updatePlugin: (name) ->
return
uninstallPlugin: (name) ->
pluginDir =
return fs.rmrfAsync(pluginDir)
_emitUpdateProcessStatus: (status, info) ->
= status
'updateProcessStatus', status, info
_emitUpdateProcessMessage: (message, info) ->
.push message
'updateProcessMessage', message, info
getUpdateProcessStatus: () ->
return {
status:
messages:
}
install: (modules) ->
info = {modules}
npmMessageListener = ( (line) => )
'npmMessage', npmMessageListener
hasErrors = false
return Promise.each(modules, (plugin) =>
(if then else )
.catch( (error) =>
env.logger.error("Error installing plugin #{plugin}: #{error.message}")
env.logger.debug(error.stack)
)
).then( =>
'npmMessage', npmMessageListener
return modules
).catch( (error) =>
'npmMessage', npmMessageListener
throw error
)
pathToPlugin: (name) ->
assert name?
assert name.match(/^pimatic.*$/)? or name is "pimatic"
return path.resolve .maindir, "..", name
getPluginList: ->
if then return
else return
getCoreInfo: ->
if then return
else return
_tranformRequestErrors: (err) ->
if err.name is 'RequestError'
throw new Error(
"""
Could not connect to the pimatic update server: #{err.message}
Either the update server is currently not available or your internet connection is down.
""")
throw err
searchForPlugin: ->
version = .packageJson.version
return = rp("http://api.pimatic.org/plugins?version=#{version}")
.catch()
.then( (res) =>
json = JSON.parse(res)
if json.error?
throw new Error ("#{json.error}: #{version}")
for name in blacklist
json = json.filter (item) -> item.name isnt name
# sort
json.sort( (a, b) => a.name.localeCompare(b.name) )
# cache for 1min
setTimeout( (=> = null), 60*1000)
return json
).catch( (err) =>
# cache errors only for 1 sec
setTimeout( (=> = null), 1*1000)
throw err
)
searchForCoreUpdate: ->
version = .packageJson.version
return = rp("http://api.pimatic.org/core?version=#{version}")
.catch()
.then( (res) =>
json = JSON.parse(res)
# cache for 1min
setTimeout( (=> = null), 60*1000)
return json
).catch( (err) =>
# cache errors only for 1 sec
setTimeout( (=> = null), 1*1000)
throw err
)
getPluginInfo: (name) ->
return if name is "pimatic"
pluginInfo = null
return .then( (plugins) =>
pluginInfo = _.find(plugins, (p) -> p.name is name)
).finally( () =>
unless pluginInfo?
env.logger.info("Could not get plugin info from update server, request info from npm")
return pluginInfo =
).then( () =>
return pluginInfo
)
getPluginInfoFromNpm: (name) ->
return rp("https://registry.npmjs.org/#{name}").then( (res) =>
packageInfos = JSON.parse(res)
if packageInfos.error?
throw new Error(
"Error getting info about #{name} from npm failed: #{packageInfos.reason}")
return getLatestCompatible(packageInfos, .packageJson.version)
)
isCompatible: (packageInfo) ->
version = .packageJson.version
pimaticRange = packageInfo.peerDependencies?.pimatic
unless pimaticRange
return null
return semver.satisfies(version, pimaticRange)
searchForPluginsWithInfo: ->
return .then( (plugins) =>
return pluginList = (
for p in plugins
name = p.name.replace 'pimatic-', ''
loadedPlugin = .pluginManager.getPlugin name
installed = p.name
packageJson = (
if installed then p.name
else null
)
listEntry = {
name: name
description: p.description
version: p.version
installed: installed
loaded: loadedPlugin?
activated:
isNewer: (if installed then semver.gt(p.version, packageJson.version) else false)
isCompatible:
}
)
)
isPimaticOutdated: ->
installed =
return .then( (latest) =>
if semver.gt(latest.version, installed.version)
return {
current: installed.version
latest: latest.version
}
else return false
)
getOutdatedPlugins: ->
return .then( (result) =>
outdated = []
for p in result
if semver.gt(p.latest, p.current)
outdated.push p
return outdated
)
getInstalledPluginUpdateVersions: ->
return .then( (plugins) =>
waiting = []
infos = []
for p in plugins
do (p) =>
installed =
waiting.push .then( (latest) =>
infos.push {
plugin: p
current: installed.version
latest: latest.version
}
)
return Promise.settle(waiting).then( (results) =>
env.logger.error(r.reason()) for r in results when r.isRejected()
ret = []
for info in infos
unless info.current?
env.logger.warn "Could not get the installed package version of #{info.plugin}"
continue
unless info.latest?
env.logger.warn "Could not get the latest version of #{info.plugin}"
continue
ret.push info
return ret
)
)
spawnPpm: (args) ->
return new Promise( (resolve, reject) =>
if
reject "npm is currently in use"
return
= yes
output = ''
npmLogger = env.logger.createSublogger("ppm")
errCode = null
errorMessage = null
onLine = ( (line) =>
line = line.toString()
if (match = line.match(/ERR! code (E[A-Z]+)/))?
errCode = match[1]
if (match = line.match(/error .* requires a C\+\+11 compiler/))?
errorMessage = match[0]
output += "#{line}\n"
if line.indexOf('npm http 304') is 0 then return
if line.match(/ERR! peerinvalid .*/) then return
"npmMessage", line
line = S(line).chompLeft('npm ').s
npmLogger.info line
)
npmEnv = _.clone(process.env)
npmEnv['HOME'] = require('path').resolve .maindir, '../..'
npmEnv['NPM_CONFIG_UNSAFE_PERM'] = true
ppmBin = './node_modules/pimatic/ppm.js'
npm = spawn(ppmBin, args, {cwd: , env: npmEnv})
stdout = byline(npm.stdout)
stdout.on "data", onLine
stderr = byline(npm.stderr)
stderr.on "data", onLine
npm.on "close", (code) =>
= no
command = ppmBin + " " + _.reduce(args, (akk, a) -> "#{akk} #{a}")
if code isnt 0
reject new Error(
"Error running \"#{command}\"" + (if errorMessage? then ": #{errorMessage}" else "")
)
else resolve(output)
)
installGitPlugin: (name) ->
return .then( (plugin) =>
dist =
unless dist? then throw new Error("dist package not found")
env.logger.info("Installing: \"#{name}\" from precompiled source (#{dist.name})")
tmpDir = path.resolve .maindir, "..", ".#{name}.tmp"
destdir =
return fs.rmrfAsync(tmpDir)
.catch()
.then( =>
return download('pimatic-ci', name, dist.name, tmpDir)
)
.then( =>
return fs.rmrfAsync(destdir)
.catch()
.then( =>
fs.moveAsync(tmpDir, destdir)
)
)
.finally( =>
fs.rmrfAsync(tmpDir)
)
)
updateGitPlugin: (name) ->
getInstalledPlugins: ->
return fs.readdirAsync("#{@framework.maindir}/..").then( (files) =>
return plugins =
(f for f in files when f.match(/^pimatic-.*/)? and f isnt "pimatic-plugin-commons")
)
getInstalledPluginsWithInfo: ->
return .then( (plugins) =>
return pluginList = (
for name in plugins
packageJson = name
name = name.replace 'pimatic-', ''
loadedPlugin = .pluginManager.getPlugin name
listEntry = {
name: name
loaded: loadedPlugin?
activated:
description: packageJson.description
version: packageJson.version
homepage: packageJson.homepage
isCompatible:
}
)
)
installUpdatesAsync: (modules) ->
return new Promise( (resolve, reject) =>
# resolve when complete
.then(resolve).catch(reject)
# or after 10 seconds to prevent a timeout
Promise.delay('still running', 10000).then(resolve)
)
getInstalledPackageInfo: (name) ->
assert name?
assert name.match(/^pimatic.*$/)? or name is "pimatic"
return JSON.parse fs.readFileSync(
"#{@pathToPlugin name}/package.json", 'utf-8'
)
getNpmInfo: (name) ->
return new Promise( (resolve, reject) =>
https.get("https://registry.npmjs.org/#{name}/latest", (res) =>
str = ""
res.on "data", (chunk) -> str += chunk
res.on "end", ->
try
info = JSON.parse(str)
if info.error?
throw new Error("Getting info about #{name} failed: #{info.reason}")
resolve info
catch e
reject e.message
).on "error", reject
)
loadPlugins: ->
# Promise chain, begin with an empty promise
chain = Promise.resolve()
for pConf, i in
do (pConf, i) =>
chain = chain.then( () =>
assert pConf?
assert pConf instanceof Object
assert pConf.plugin? and typeof pConf.plugin is "string"
if pConf.active is false
return Promise.resolve()
fullPluginName = "pimatic-#{pConf.plugin}"
return Promise.try( =>
# If the plugin folder already exist
return (
if then Promise.resolve()
else
).then( =>
return .then( ([plugin, packageInfo]) =>
# Check config
configSchema =
if typeof plugin.prepareConfig is "function"
plugin.prepareConfig(pConf)
if configSchema?
._validateConfig(pConf, configSchema, "config of #{fullPluginName}")
pConf = declapi.enhanceJsonSchemaWithDefaults(configSchema, pConf)
else
env.logger.warn(
"package.json of \"#{fullPluginName}\" has no \"configSchema\" property. " +
"Could not validate config."
)
)
)
)
).catch( (error) ->
# If an error occurs log an ignore it.
env.logger.error error.message
env.logger.debug error.stack
)
return chain
_getConfigSchemaFromPackageInfo: (packageInfo) ->
unless packageInfo.configSchema?
return null
pathToSchema = path.resolve(
,
packageInfo.configSchema
)
configSchema = require(pathToSchema)
unless configSchema._normalized
configSchema.properties.plugin = {
type: "string"
}
configSchema.properties.active = {
type: "boolean"
required: false
}
._normalizeScheme(configSchema)
return configSchema
initPlugins: ->
for plugin in
try
plugin.plugin.init(.app, , plugin.config)
catch err
env.logger.error(
"Could not initialize the plugin \"#{plugin.config.plugin}\": " +
err.message
)
env.logger.debug err.stack
registerPlugin: (plugin, config, packageInfo) ->
assert plugin? and plugin instanceof env.plugins.Plugin
assert config? and config instanceof Object
.push {plugin, config, packageInfo}
"plugin", plugin
getPlugin: (name) ->
assert name?
assert typeof name is "string"
for p in
if p.config.plugin is name then return p.plugin
return null
getPluginConfig: (name) ->
for plugin in .config.plugins
if plugin.plugin is name then return plugin
return null
isActivated: (name) ->
for plugin in .config.plugins
if plugin.plugin is name
return if plugin.active? then plugin.active else true
return false
getPluginConfigSchema: (name) ->
assert name?
assert typeof name is "string"
packageInfo =
return
updatePluginConfig: (pluginName, config) ->
assert pluginName?
assert typeof pluginName is "string"
config.plugin = pluginName
fullPluginName = "pimatic-#{pluginName}"
configSchema =
if configSchema?
._validateConfig(config, configSchema, "config of #{fullPluginName}")
for plugin, i in .config.plugins
if plugin.plugin is pluginName
.config.plugins[i] = config
.emit 'config'
return
.config.plugins.push(config)
.emit 'config'
removePluginFromConfig: (pluginName) ->
removed = _.remove(.config.plugins, (p) => p.plugin is pluginName)
if removed.length > 0
.emit 'config'
return removed.length > 0
setPluginActivated: (pluginName, active) ->
for plugin, i in .config.plugins
if plugin.plugin is pluginName
if !!plugin.active isnt !!active
plugin.active = active
.emit 'config'
return true
return false
getCallingPlugin: () ->
stack = new Error().stack.toString()
matches = stack.match(/^.+?\/node_modules\/(pimatic-.+?)\//m)
if matches?
return matches[1]
else
return 'pimatic'
requrieRestart: () ->
= true
doesRequireRestart: () ->
return
class Plugin extends require('events').EventEmitter
name: null
init: ->
throw new Error("Your plugin must implement init")
#createDevice: (config) ->
return exports = {
PluginManager
Plugin
}