@litexa/core
Version:
Litexa, a programming language for writing Alexa skills
735 lines (618 loc) • 25.3 kB
text/coffeescript
###
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
###
fs = require 'fs'
path = require 'path'
util = require 'util'
child_process = require 'child_process'
assert = require 'assert'
smapi = require '../api/smapi'
{ JSONValidator } = require('../../parser/jsonValidator').lib
testObjectsEqual = (a, b) ->
if Array.isArray a
unless Array.isArray b
throw "#{JSON.stringify a} NOT EQUAL TO #{JSON.stringify b}"
unless a.length == b.length
throw "#{JSON.stringify a} NOT EQUAL TO #{JSON.stringify b}"
for v, i in a
testObjectsEqual v, b[i]
return
if typeof(a) == 'object'
unless typeof(b) == 'object'
throw "#{JSON.stringify a} NOT EQUAL TO #{JSON.stringify b}"
# check all B keys are present in A, as long as B key actually has a value
for k, v of b
continue unless v?
unless k of a
throw "#{JSON.stringify a} NOT EQUAL TO #{JSON.stringify b}"
# check that all values in A are the same in B
for k, v of a
testObjectsEqual v, b[k]
return
unless a == b
throw "#{JSON.stringify a} NOT EQUAL TO #{JSON.stringify b}"
logger = console
writeFilePromise = util.promisify fs.writeFile
exec = util.promisify child_process.exec
askProfile = null
module.exports =
deploy: (context, overrideLogger) ->
logger = overrideLogger
askProfile = context.deploymentOptions?.askProfile
manifestContext = {}
logger.log "beginning manifest deployment"
smapi.prepare logger
.then ->
loadSkillInfo context, manifestContext
.then ->
getManifestFromSkillInfo context, manifestContext
.then ->
buildSkillManifest context, manifestContext
.then ->
createOrUpdateSkill context, manifestContext
.then ->
updateModel context, manifestContext
.then ->
enableSkill context, manifestContext
.then ->
logger.log "manifest deployment complete, #{logger.runningTime()}ms"
.catch (err) ->
if err?.code?
logger.error "SMAPI error: #{err.code ? ''} #{err.message}"
else
if err.stack?
logger.error err.stack
else
logger.error JSON.stringify err
if typeof(err) == 'string'
# intended for the cli user
throw err
throw "failed manifest deployment"
loadSkillInfo = (context, manifestContext) ->
logger.log "loading skill.json"
infoFilename = path.join context.projectRoot, 'skill'
try
manifestContext.skillInfo = require infoFilename
catch err
if err.code == 'MODULE_NOT_FOUND'
writeDefaultManifest(context, path.join context.projectRoot, 'skill.coffee')
throw "skill.* was not found in project root #{context.projectRoot}, so a default has been
generated in CoffeeScript. Please modify as appropriate and try deployment again."
logger.error err
throw "Failed to parse skill manifest #{infoFilename}"
Promise.resolve()
getManifestFromSkillInfo = (context, manifestContext) ->
logger.log "building skill manifest"
unless 'manifest' of manifestContext.skillInfo
throw "Didn't find a 'manifest' property in the skill.* file. Has it been corrupted?"
deploymentTarget = context.projectInfo.variant
# Let's check if a deployment target specific manifest is being exported in the form of:
# { deploymentTargetName: { manifest: {...} } }
for key of manifestContext.skillInfo
if key == deploymentTarget
manifestContext.fileManifest = manifestContext.skillInfo[deploymentTarget].manifest
unless manifestContext.fileManifest?
# If we didn't find a deployment-target-specific manifest, let's try to get the manifest
# from the top level of the file export.
manifestContext.fileManifest = manifestContext.skillInfo.manifest
unless manifestContext.fileManifest?
throw "skill* is neither exporting a top-level 'manifest' key, nor a 'manifest' nested below
the current deployment target '#{deploymentTarget}' - please export a manifest for either and
re-deploy."
Promise.resolve()
buildSkillManifest = (context, manifestContext) ->
lambdaArn = context.artifacts.get 'lambdaARN'
unless lambdaArn
throw "Missing lambda ARN during manifest deployment. Has the Lambda been deployed yet?"
fileManifest = manifestContext.fileManifest
# pull the skill file's manifest into our template merge manifest, which
# will set any non-critical values that were missing in the file manifest
mergeManifest =
manifestVersion: "1.0"
publishingInformation:
isAvailableWorldwide: false,
distributionCountries: [ 'US' ]
distributionMode: 'PUBLIC'
category: 'GAMES'
testingInstructions: 'no instructions'
gadgetSupport: undefined
privacyAndCompliance:
allowsPurchases: false
usesPersonalInfo: false
isChildDirected: false
isExportCompliant: true
containsAds: false
apis:
custom:
endpoint:
uri: lambdaArn
regions:
NA:
endpoint:
uri: lambdaArn
interfaces: []
#events: {}
#permissions: {}
unless 'publishingInformation' of fileManifest
throw "skill.json is missing publishingInformation. Has it been corrupted?"
interfaces = mergeManifest.apis.custom.interfaces
for key of fileManifest
switch key
when 'publishingInformation'
# copy over all sub keys of publishing information
for k, v of mergeManifest.publishingInformation
mergeManifest.publishingInformation[k] = fileManifest.publishingInformation[k] ? v
unless 'locales' of fileManifest.publishingInformation
throw "skill.json is missing locales in publishingInformation.
Has it been corrupted?"
# dig through specified locales. TODO: compare with code language support?
mergeManifest.publishingInformation.locales = {}
manifestContext.locales = []
# check for icon files that were deployed via 'assets' directories
deployedIconAssets = context.artifacts.get('deployedIconAssets') ? {}
manifestContext.deployedIconAssetsMd5Sum = ''
for locale, data of fileManifest.publishingInformation.locales
# copy over expected keys, ignore the rest
expectedKeys = ['name', 'summary', 'description'
'examplePhrases', 'keywords', 'smallIconUri',
'largeIconUri']
copy = {}
for k in expectedKeys
copy[k] = data[k]
# check for language-specific skill icon files that were deployed via 'assets'
localeIconAssets = deployedIconAssets[locale] ? deployedIconAssets[locale[0..1]]
# fallback to default skill icon files, if no locale-specific icons found
unless localeIconAssets?
localeIconAssets = deployedIconAssets.default
# Unless user specified their own icon URIs, use the deployed asset icons.
# If neither a URI is specified nor an asset icon is deployed, throw an error.
if copy.smallIconUri?
copy.smallIconUri = copy.smallIconUri
else
smallIconFileName = 'icon-108.png'
if localeIconAssets? and localeIconAssets[smallIconFileName]?
smallIcon = localeIconAssets[smallIconFileName]
manifestContext.deployedIconAssetsMd5Sum += smallIcon.md5
copy.smallIconUri = smallIcon.url
else
throw "Required smallIconUri not found for locale #{locale}. Please specify a
'smallIconUri' in the skill manifest, or deploy an '#{smallIconFileName}' image via
assets."
if copy.largeIconUri?
copy.largeIconUri = copy.largeIconUri
else
largeIconFileName = 'icon-512.png'
if localeIconAssets? and localeIconAssets[largeIconFileName]?
largeIcon = localeIconAssets[largeIconFileName]
manifestContext.deployedIconAssetsMd5Sum += largeIcon.md5
copy.largeIconUri = largeIcon.url
else
throw "Required largeIconUri not found for locale #{locale}. Please specify a
'smallIconUri' in the skill manifest, or deploy an '#{largeIconFileName}' image via
assets."
mergeManifest.publishingInformation.locales[locale] = copy
invocationName = context.deploymentOptions.invocation?[locale] ? data.invocation ? data.name
# until such time we can correctly define the acceptable character set, we're
# better off disabling this sanitization here and letting SMAPI fail and error out.
#invocationName = invocationName.replace /[^a-zA-Z0-9 ]/g, ' '
#invocationName = invocationName.toLowerCase()
if context.deploymentOptions.invocationSuffix?
invocationName += " #{context.deploymentOptions.invocationSuffix}"
maxLength = 160
if copy.summary.length > maxLength
copy.summary = copy.summary[0..maxLength - 4] + '...'
logger.log "uploaded summary length: #{copy.summary.length}"
logger.warning "summary for locale #{locale} was too long, truncated it to #{maxLength}
characters"
unless copy.examplePhrases
copy.examplePhrases = [
"Alexa, launch <invocation>"
"Alexa, open <invocation>"
"Alexa, play <invocation>"
]
copy.examplePhrases = for phrase in copy.examplePhrases
phrase.replace /\<invocation\>/gi, invocationName
# if 'production' isn't in the deployment target name, assume it's a development skill
# and append a ' (target)' suffix to its name
if (!context.projectInfo.variant.includes('production'))
copy.name += " (#{context.projectInfo.variant})"
manifestContext.locales.push {
code: locale
invocation: invocationName
}
unless manifestContext.locales.length > 0
throw "No locales found in the skill.json manifest. Please add at least one."
when 'privacyAndCompliance'
# dig through these too
for k, v of mergeManifest.privacyAndCompliance
mergeManifest.privacyAndCompliance[k] = fileManifest.privacyAndCompliance[k] ? v
if fileManifest.privacyAndCompliance.locales?
mergeManifest.privacyAndCompliance.locales = {}
for locale, data of fileManifest.privacyAndCompliance.locales
mergeManifest.privacyAndCompliance.locales[locale] =
privacyPolicyUrl: data.privacyPolicyUrl
termsOfUseUrl: data.termsOfUseUrl
when 'apis'
# copy over any keys the user has specified, they might know some
# advanced information that hasn't been described in a plugin yet,
# trust the user on this
if fileManifest.apis?.custom?.interfaces?
for i in fileManifest.apis.custom.interfaces
interfaces.push i
else
# no opinion on any remaining keys, so if they exist, copy them over
mergeManifest[key] = fileManifest[key]
# collect which APIs are actually in use and merge them
requiredAPIs = {}
context.skill.collectRequiredAPIs requiredAPIs
for k, extension of context.projectInfo.extensions
continue unless extension.compiler?.requiredAPIs?
for a in extension.compiler.requiredAPIs
requiredAPIs[a] = true
for apiName of requiredAPIs
found = false
for i in interfaces
if i.type == apiName
found = true
unless found
logger.log "enabling interface #{apiName}"
interfaces.push { type: apiName }
# save it for later, wrap it one deeper for SMAPI
manifestContext.manifest = mergeManifest
finalManifest = { manifest: mergeManifest }
# extensions can opt to validate the manifest, in case there are other
# dependencies they want to assert
for extensionName, extension of context.projectInfo.extensions
validator = new JSONValidator finalManifest
extension.compiler?.validators?.manifest { validator, skill: context.skill }
if validator.errors.length > 0
logger.error e for e in validator.errors
throw "Errors encountered with the manifest, cannot continue."
# now that we have the manifest, we can also validate the models
for region of finalManifest.manifest.publishingInformation.locales
model = context.skill.toModelV2(region)
validator = new JSONValidator model
for extensionName, extension of context.projectInfo.extensions
extension.compiler?.validators?.model { validator, skill: context.skill }
if validator.errors.length > 0
logger.error e for e in validator.errors
throw "Errors encountered with model in #{region} language, cannot continue"
manifestContext.manifestFilename = path.join(context.deployRoot, 'skill.json')
writeFilePromise manifestContext.manifestFilename, JSON.stringify(finalManifest, null, 2), 'utf8'
createOrUpdateSkill = (context, manifestContext) ->
skillId = context.artifacts.get 'skillId'
if skillId?
manifestContext.skillId = skillId
logger.log "skillId found in artifacts, getting information for #{manifestContext.skillId}"
updateSkill context, manifestContext
else
logger.log "no skillId found in artifacts, creating new skill"
createSkill context, manifestContext
parseSkillInfo = (data) ->
try
data = JSON.parse data
catch err
logger.verbose data
logger.error err
throw "failed to parse JSON response from SMAPI"
info = {
status: data.manifest?.lastUpdateRequest?.status ? null
errors: data.manifest?.lastUpdateRequest?.errors
manifest: data.manifest
raw: data
}
if info.errors
info.errors = JSON.stringify(info.errors, null, 2)
logger.verbose info.errors
logger.verbose "skill is in #{info.status} state"
return info
updateSkill = (context, manifestContext) ->
params =
'skill-id': manifestContext.skillId
if smapi.version.major < 2
command = 'get-skill'
else
command = 'get-skill-manifest'
params.stage = 'development'
smapi.call { askProfile, command, params, logChannel: logger }
.catch (err) ->
if err.code == 404
Promise.reject "[code: #{err.code}] The skill ID stored in artifacts.json doesn't seem to exist in the deployment
account. Have you deleted it manually in the dev console? If so, please delete it from the
artifacts.json and try again."
else
Promise.reject "[code: #{err.code}] Failed to get the current skill manifest. ask returned: #{err.message}"
.then (data) ->
needsUpdating = false
info = parseSkillInfo data
if info.status == 'FAILED'
needsUpdating = true
else
try
testObjectsEqual info.manifest, manifestContext.manifest
logger.log "deployed skill manifest matches local"
catch err
logger.verbose err
logger.log "deployed skill manifest does not match local, updating"
needsUpdating = true
unless context.artifacts.get('skill-manifest-assets-md5') == manifestContext.deployedIconAssetsMd5Sum
logger.log "skill icons changed since last update"
needsUpdating = true
unless needsUpdating
logger.log "skill manifest up to date"
return Promise.resolve()
logger.log "updating skill manifest"
if smapi.version.major < 2
command = 'update-skill'
params =
'skill-id': manifestContext.skillId
'file': manifestContext.manifestFilename
else
command = 'update-skill-manifest'
params =
'skill-id': manifestContext.skillId
'manifest': "file:#{manifestContext.manifestFilename}"
'stage': 'development'
smapi.call { askProfile, command, params, logChannel: logger }
.then (data) ->
waitForSuccess context, manifestContext.skillId, 'update-skill'
.then ->
context.artifacts.save 'skill-manifest-assets-md5', manifestContext.deployedIconAssetsMd5Sum
.catch (err) ->
Promise.reject err
waitForSuccess = (context, skillId, operation) ->
return new Promise (resolve, reject) ->
checkStatus = ->
logger.log "waiting for skill status after #{operation}"
smapi.call {
askProfile
command: 'get-skill-status'
params: { 'skill-id': skillId }
logChannel: logger
}
.then (data) ->
info = parseSkillInfo data
switch info.status
when 'FAILED'
logger.error info.errors
return reject "skill in FAILED state"
when 'SUCCEEDED'
logger.log "#{operation} succeeded"
context.artifacts.save 'skillId', skillId
return resolve()
when 'IN_PROGRESS'
setTimeout checkStatus, 1000
else
logger.verbose data
return reject "unknown skill state: #{info.status} while waiting on SMAPI"
Promise.resolve()
.catch (err) ->
Promise.reject err
checkStatus()
createSkill = (context, manifestContext) ->
if smapi.version.major < 2
command = 'create-skill'
params =
'file': manifestContext.manifestFilename
else
command = 'create-skill-for-vendor'
params =
'manifest': "file:#{manifestContext.manifestFilename}"
smapi.call { askProfile, command, params, logChannel: logger }
.then (data) ->
if smapi.version.major < 2
# dig out the skill id
lines = data.split '\n'
skillId = null
for line in lines
[k, v] = line.split ':'
if k.toLowerCase().indexOf('skill id') == 0
skillId = v.trim()
break
else
result = JSON.parse data
skillId = result.skillId
unless skillId?
throw "failed to extract skill ID from ask cli response to create-skill"
logger.log "in progress skill id #{skillId}"
manifestContext.skillId = skillId
waitForSuccess context, skillId, 'create-skill'
.catch (err) ->
Promise.reject err
writeDefaultManifest = (context, filename) ->
logger.log "writing default skill.json"
# try to make a nice looking name from the
# what was the directory name
name = context.projectInfo.name
name = name.replace /[_\.\-]/gi, ' '
name = name.replace /\s+/gi, ' '
name = (name.split(' '))
name = ( w[0].toUpperCase() + w[1...] for w in name )
name = name.join ' '
manifest = """
###
This file exports an object that is a subset of the data
specified for an Alexa skill manifest as defined at
https://developer.amazon.com/docs/smapi/skill-manifest.html
Please fill in fields as appropriate for this skill,
including the name, descriptions, more regions, etc.
At deployment time, this data will be augmented with
generated information based on your skill code.
###
module.exports =
manifest:
publishingInformation:
isAvailableWorldwide: false,
distributionCountries: [ 'US' ]
distributionMode: 'PUBLIC'
category: 'GAMES'
testingInstructions: "replace with testing instructions"
locales:
"en-US":
name: "#{name}"
invocation: "#{name.toLowerCase()}"
summary: "replace with brief description, no longer than 120 characters"
description: "\""Longer description, goes to the skill store.
Line breaks are supported."\""
examplePhrases: [
"Alexa, launch #{name}"
"Alexa, open #{name}"
"Alexa, play #{name}"
]
keywords: [
'game'
'fun'
'single player'
'modify this list as appropriate'
]
privacyAndCompliance:
allowsPurchases: false
usesPersonalInfo: false
isChildDirected: false
isExportCompliant: true
containsAds: false
locales:
"en-US":
privacyPolicyUrl: "https://www.example.com/privacy.html",
termsOfUseUrl: "https://www.example.com/terms.html"
"""
fs.writeFileSync filename, manifest, 'utf8'
waitForModelSuccess = (context, skillId, locale, operation) ->
return new Promise (resolve, reject) ->
checkStatus = ->
logger.log "waiting for model #{locale} status after #{operation}"
smapi.call {
askProfile
command: 'get-skill-status'
params: { 'skill-id': skillId }
logChannel: logger
}
.then (data) ->
try
info = JSON.parse data
info = info.interactionModel[locale]
catch err
logger.verbose data
logger.error err
return reject "failed to parse SMAPI result"
switch info.lastUpdateRequest?.status
when 'FAILED'
logger.error info.errors
return reject "skill in FAILED state"
when 'SUCCEEDED'
logger.log "model #{operation} succeeded"
context.artifacts.save "skill-model-etag-#{locale}", info.eTag
return resolve()
when 'IN_PROGRESS'
setTimeout checkStatus, 1000
else
logger.verbose data
return reject "unknown skill state: #{info.status} while waiting on SMAPI"
Promise.resolve()
.catch (err) ->
reject(err)
checkStatus()
updateModel = (context, manifestContext) ->
promises = []
for locale in manifestContext.locales
promises.push updateModelForLocale context, manifestContext, locale
Promise.all promises
updateModelForLocale = (context, manifestContext, localeInfo) ->
locale = localeInfo.code
modelDeployStart = new Date
params = {
'skill-id': manifestContext.skillId
locale: locale
}
if smapi.version.major < 2
command = 'get-model'
else
command = 'get-interaction-model'
params.stage = 'development'
smapi.call { askProfile, command, params, logChannel: logger }
.catch (err) ->
# it's fine if it doesn't exist yet, we'll upload
unless err.code == 404
Promise.reject err
Promise.resolve "{}"
.then (data) ->
model = context.skill.toModelV2 locale
# patch in the invocation from the skill manifest
model.languageModel.invocationName = localeInfo.invocation
# note, SMAPI needs an extra
# interactionModel key around the model
model =
interactionModel:model
filename = path.join context.deployRoot, "model-#{locale}.json"
fs.writeFileSync filename, JSON.stringify(model, null, 2), 'utf8'
needsUpdate = false
try
data = JSON.parse data
# the version number is a lamport clock, will always mismatch
delete data.version
testObjectsEqual model, data
logger.log "#{locale} model up to date"
catch err
logger.verbose err
logger.log "#{locale} model mismatch"
needsUpdate = true
unless needsUpdate
logger.log "#{locale} model is up to date"
return Promise.resolve()
logger.log "#{locale} model update beginning"
smapi.getVersion logger
.then (version) ->
params =
'skill-id': manifestContext.skillId
locale: locale
if smapi.version.major < 2
command = 'update-model'
params.file = filename
else
command = 'set-interaction-model'
params['interaction-model'] = "file:#{filename}"
params.stage = 'development'
smapi.call { askProfile, command, params, logChannel: logger }
.then ->
waitForModelSuccess context, manifestContext.skillId, locale, 'update-model'
.then ->
dt = (new Date) - modelDeployStart
logger.log "#{locale} model update complete, total time #{dt}ms"
.catch (err) ->
if err.message
logger.important err.message
Promise.reject "Failed to upload model"
else
Promise.reject err
enableSkill = (context, manifestContext) ->
logger.log "ensuring skill is enabled for testing"
params =
'skill-id': manifestContext.skillId
if smapi.version.major < 2
command = 'enable-skill'
else
command = 'set-skill-enablement'
params.stage = 'development'
smapi.call { askProfile, command, params, logChannel: logger }
.catch (err) ->
Promise.reject err
module.exports.generateManifest = (options, skill) ->
context = await (require '../deploy.coffee').buildDeploymentContext options
manifestContext = {}
require('../../deployment/artifacts.coffee').loadArtifacts { context, logger: context.logger }
.then ->
artifacts = context.artifacts
context.artifacts =
get: (key) -> artifacts.tryGet(key) ? "** STUB, #{key} not available yet **"
loadSkillInfo context, manifestContext
.then ->
getManifestFromSkillInfo context, manifestContext
.then ->
buildSkillManifest context, manifestContext
.then ->
return manifestContext.manifest
module.exports.testing =
getManifestFromSkillInfo: getManifestFromSkillInfo